From 9a3f6b21d25861f5c8e9e8baf99b04e066aa1c22 Mon Sep 17 00:00:00 2001
From: LP B
Date: Mon, 3 Feb 2025 14:20:35 +0100
Subject: [PATCH 001/212] feat(app/service-details): hide view while loading
data (#348)
---
app/docker/views/services/edit/service.html | 525 +++++++++---------
.../views/services/edit/serviceController.js | 4 +
2 files changed, 270 insertions(+), 259 deletions(-)
diff --git a/app/docker/views/services/edit/service.html b/app/docker/views/services/edit/service.html
index fc49a3e13..406c487ca 100644
--- a/app/docker/views/services/edit/service.html
+++ b/app/docker/views/services/edit/service.html
@@ -1,274 +1,281 @@
-
-
-
-
This service is being updated. Editing this service is currently disabled.
-
Refresh to see if this service has finished updated.
+
-
-
-
-
-
-
-
-
- Name
-
-
-
- {{ service.Name }}
-
-
- ID
- {{ service.Id }}
-
-
- Created at
- {{ service.CreatedAt | getisodate }}
-
-
- Last updated at
- {{ service.UpdatedAt | getisodate }}
-
-
- Version
- {{ service.Version }}
-
-
- Scheduling mode
- {{ service.Mode }}
-
-
- Replicas
-
-
+
+
+
+
+
+
+
+
+ Name
+
-
-
-
-
- Image
- {{ service.Image }}
-
-
-
-
-
-
-
-
-
- {{ webhookURL | truncatelr }}
-
-
- Copy link
-
-
-
-
+
+ {{ service.Name }}
+
+
+ ID
+ {{ service.Id }}
+
+
+ Created at
+ {{ service.CreatedAt | getisodate }}
+
+
+ Last updated at
+ {{ service.UpdatedAt | getisodate }}
+
+
+ Version
+ {{ service.Version }}
+
+
+ Scheduling mode
+ {{ service.Mode }}
+
+
+ Replicas
+
+
+
-
-
-
-
-
-
- Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback
+
+ Image
+ {{ service.Image }}
+
+
+
+
+
Service webhook
+
- Update in progress...
-
-
-
-
- Rollback the service
+
+
+
+
+
+
+ {{ webhookURL | truncatelr }}
+
+
+ Copy link
+
+
+
+
+
+
+
+
+
+
+
+ Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback
-
-
-
-
-
-
-
- Do you need help? View the Docker Service documentation here .
-
-
+
+
+
+
+
+
+ Do you need help? View the Docker Service documentation here .
+
+
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
Container specification
+
+
+
+
+
+
+
+
+
+
+
Networks & ports
+
+
+
+
+
+
+
+
+
+
+
+
Service specification
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
Container specification
-
-
-
-
-
-
-
-
-
-
-
-
Networks & ports
-
-
-
-
-
-
-
-
-
-
-
-
Service specification
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/docker/views/services/edit/serviceController.js b/app/docker/views/services/edit/serviceController.js
index 69474b398..3344ef2ab 100644
--- a/app/docker/views/services/edit/serviceController.js
+++ b/app/docker/views/services/edit/serviceController.js
@@ -731,6 +731,7 @@ angular.module('portainer.docker').controller('ServiceController', [
};
function initView() {
+ $scope.isLoading = true;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
@@ -855,6 +856,9 @@ angular.module('portainer.docker').controller('ServiceController', [
$scope.secrets = [];
$scope.configs = [];
Notifications.error('Failure', err, 'Unable to retrieve service details');
+ })
+ .finally(() => {
+ $scope.isLoading = false;
});
}
From c0d30a455f606409d49d95df0e5c221893c2f233 Mon Sep 17 00:00:00 2001
From: LP B
Date: Mon, 3 Feb 2025 20:25:25 +0100
Subject: [PATCH 002/212] fix(api/edge): backend panic on edge stack removal
(#371)
---
api/http/handler/edgestacks/edgestack_status_update.go | 4 ++--
.../edgestacks/edgestack_status_update_coordinator.go | 5 +++++
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go
index 4cf912030..fef5a6927 100644
--- a/api/http/handler/edgestacks/edgestack_status_update.go
+++ b/api/http/handler/edgestacks/edgestack_status_update.go
@@ -79,7 +79,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
}
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
- return handler.updateEdgeStackStatus(stack, endpoint, r, stack.ID, payload)
+ return handler.updateEdgeStackStatus(stack, stack.ID, payload)
}
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
@@ -99,7 +99,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return response.JSON(w, stack)
}
-func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, endpoint *portainer.Endpoint, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
+func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
if payload.Version > 0 && payload.Version < stack.Version {
return stack, nil
}
diff --git a/api/http/handler/edgestacks/edgestack_status_update_coordinator.go b/api/http/handler/edgestacks/edgestack_status_update_coordinator.go
index f81e2fc3e..885b4c6da 100644
--- a/api/http/handler/edgestacks/edgestack_status_update_coordinator.go
+++ b/api/http/handler/edgestacks/edgestack_status_update_coordinator.go
@@ -60,6 +60,11 @@ func (c *EdgeStackStatusUpdateCoordinator) loop() {
return err
}
+ // Return early when the agent tries to update the status on a deleted stack
+ if stack == nil {
+ return nil
+ }
+
// 2. Mutate the edge stack opportunistically until there are no more pending updates
for {
stack, err = u.updateFn(stack)
From a50a9c56176eb853f122728529150b1a61faa51c Mon Sep 17 00:00:00 2001
From: LP B
Date: Mon, 3 Feb 2025 20:50:24 +0100
Subject: [PATCH 003/212] fix(app/edge): edge stacks webhooks cannot be
disabled once created (#372)
---
.../edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx
index d0d1e5373..5d7956822 100644
--- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NonGitStackForm.tsx
@@ -143,7 +143,7 @@ export function NonGitStackForm({ edgeStack }: { edgeStack: EdgeStack }) {
updateVersion,
webhook: values.webhookEnabled
? edgeStack.Webhook || createWebhookId()
- : undefined,
+ : '',
envVars: values.envVars,
rollbackTo: values.rollbackTo,
staggerConfig: values.staggerConfig,
From 379711951c341af366abed2041bd506fa4134bc9 Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Tue, 4 Feb 2025 09:41:24 +1300
Subject: [PATCH 004/212] fix(edgegroup): failed to associate env to static
edge group [BE-11599] (#368)
---
api/http/handler/edgegroups/edgegroup_update.go | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go
index 2d04f7cd2..e67607d92 100644
--- a/api/http/handler/edgegroups/edgegroup_update.go
+++ b/api/http/handler/edgegroups/edgegroup_update.go
@@ -167,7 +167,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 {
+ if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return err
}
@@ -183,6 +183,11 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi
edgeStackSet[edgeStackID] = true
}
+ if relation == nil {
+ relation = &portainer.EndpointRelation{
+ EndpointID: endpoint.ID,
+ }
+ }
relation.EdgeStacks = edgeStackSet
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)
From 5af0859f67e5cff76399277ee5769411f14f42f3 Mon Sep 17 00:00:00 2001
From: James Player
Date: Tue, 4 Feb 2025 15:34:33 +1300
Subject: [PATCH 005/212] fix(datatables): "Select all" should select only
elements of the current page (#376)
---
.../components/datatables/Datatable.test.tsx | 44 +++++++++++++++++++
.../components/datatables/select-column.tsx | 13 ++++--
2 files changed, 54 insertions(+), 3 deletions(-)
diff --git a/app/react/components/datatables/Datatable.test.tsx b/app/react/components/datatables/Datatable.test.tsx
index 25e1ed1eb..3b5a1e63f 100644
--- a/app/react/components/datatables/Datatable.test.tsx
+++ b/app/react/components/datatables/Datatable.test.tsx
@@ -155,6 +155,50 @@ describe('Datatable', () => {
expect(screen.getByText('No data available')).toBeInTheDocument();
});
+
+ it('selects/deselects only page rows when select all is clicked', () => {
+ render(
+
+ );
+
+ const selectAllCheckbox = screen.getByLabelText('Select all rows');
+ fireEvent.click(selectAllCheckbox);
+
+ // Check if all rows on the page are selected
+ expect(screen.getByText('2 item(s) selected')).toBeInTheDocument();
+
+ // Deselect
+ fireEvent.click(selectAllCheckbox);
+ const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
+ expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
+ });
+
+ it('selects/deselects all rows including other pages when select all is clicked with shift key', () => {
+ render(
+
+ );
+
+ const selectAllCheckbox = screen.getByLabelText('Select all rows');
+ fireEvent.click(selectAllCheckbox, { shiftKey: true });
+
+ // Check if all rows on the page are selected
+ expect(screen.getByText('3 item(s) selected')).toBeInTheDocument();
+
+ // Deselect
+ fireEvent.click(selectAllCheckbox, { shiftKey: true });
+ const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
+ expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
+ });
});
// Test the defaultGlobalFilterFn used in searches
diff --git a/app/react/components/datatables/select-column.tsx b/app/react/components/datatables/select-column.tsx
index 6e5b4a920..f20f4dd0b 100644
--- a/app/react/components/datatables/select-column.tsx
+++ b/app/react/components/datatables/select-column.tsx
@@ -11,15 +11,22 @@ export function createSelectColumn(dataCy: string): ColumnDef {
{
+ // Select all rows if shift key is held down, otherwise only page rows
+ if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey) {
+ table.getToggleAllRowsSelectedHandler()(e);
+ return;
+ }
+ table.getToggleAllPageRowsSelectedHandler()(e);
+ }}
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
onClick={(e) => {
e.stopPropagation();
}}
aria-label="Select all rows"
- title="Select all rows"
+ title="Select all rows. Hold shift key to select across all pages."
/>
),
cell: ({ row, table }) => (
From bc19d6592fad172faaacdd0b8a42a17c35cc5daf Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Wed, 5 Feb 2025 12:17:51 +1300
Subject: [PATCH 006/212] fix(libstack): cannot open std edge stack log page
[BE-11603] (#384)
---
pkg/libstack/compose/composeplugin.go | 6 ++-
pkg/libstack/compose/logwriter.go | 58 +++++++++++++++++++++++++++
2 files changed, 62 insertions(+), 2 deletions(-)
create mode 100644 pkg/libstack/compose/logwriter.go
diff --git a/pkg/libstack/compose/composeplugin.go b/pkg/libstack/compose/composeplugin.go
index 7b6a639c4..3162c6ab7 100644
--- a/pkg/libstack/compose/composeplugin.go
+++ b/pkg/libstack/compose/composeplugin.go
@@ -31,8 +31,10 @@ const PortainerEdgeStackLabel = "io.portainer.edge_stack_id"
var mu sync.Mutex
func init() {
- // Redirect Compose logging to zerolog
- logrus.SetOutput(log.Logger)
+ logrus.SetOutput(&LogrusToZerologWriter{})
+ logrus.SetFormatter(&logrus.TextFormatter{
+ DisableTimestamp: true,
+ })
}
func withCli(
diff --git a/pkg/libstack/compose/logwriter.go b/pkg/libstack/compose/logwriter.go
new file mode 100644
index 000000000..477433552
--- /dev/null
+++ b/pkg/libstack/compose/logwriter.go
@@ -0,0 +1,58 @@
+package compose
+
+import (
+ "strings"
+
+ "github.com/rs/zerolog"
+ "github.com/rs/zerolog/log"
+)
+
+// LogrusToZerologWriter is a custom logrus writer that writes logrus logs to zerolog.
+// logrus is the logging library used by Docker Compose.
+type LogrusToZerologWriter struct{}
+
+func (ltzw *LogrusToZerologWriter) Write(p []byte) (n int, err error) {
+ logMessage := string(p)
+ logMessage = strings.TrimSuffix(logMessage, "\n")
+
+ // Parse the log level and message from the logrus log.
+ // This assumes logrus's default text format.
+ var level, message string
+ if strings.HasPrefix(logMessage, "time=") {
+ // Example logrus log: `time="2023-10-01T12:34:56Z" level=info msg="This is a log message" key=value`
+ parts := strings.SplitN(logMessage, " ", 4)
+ if len(parts) >= 3 {
+ level = strings.TrimPrefix(parts[1], "level=")
+ message = strings.TrimPrefix(parts[2], "msg=")
+ message = strings.Trim(message, `"`)
+ }
+ } else {
+ // Fallback for simpler log formats.
+ level = "info"
+ message = logMessage
+ }
+
+ // Map logrus levels to zerolog levels.
+ var zlogLevel zerolog.Level
+ switch level {
+ case "debug":
+ zlogLevel = zerolog.DebugLevel
+ case "info":
+ zlogLevel = zerolog.InfoLevel
+ case "warn", "warning":
+ zlogLevel = zerolog.WarnLevel
+ case "error":
+ zlogLevel = zerolog.ErrorLevel
+ case "fatal":
+ zlogLevel = zerolog.FatalLevel
+ case "panic":
+ zlogLevel = zerolog.PanicLevel
+ default:
+ zlogLevel = zerolog.InfoLevel
+ }
+
+ // Log the message using zerolog.
+ log.WithLevel(zlogLevel).Msg(message)
+
+ return len(p), nil
+}
From 678cd5455332dcac9f9c00eeed0ee36ef448d93c Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Wed, 5 Feb 2025 15:03:39 +1300
Subject: [PATCH 007/212] security: cve-2024-45338 develop (#386)
---
binary-version.json | 8 ++++----
go.mod | 2 +-
go.sum | 4 ++--
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/binary-version.json b/binary-version.json
index c91bf37a6..914838f53 100644
--- a/binary-version.json
+++ b/binary-version.json
@@ -1,6 +1,6 @@
{
- "docker": "v27.1.2",
- "helm": "v3.16.4",
- "kubectl": "v1.31.4",
- "mingit": "2.46.0.1"
+ "docker": "v27.5.1",
+ "helm": "v3.17.0",
+ "kubectl": "v1.32.1",
+ "mingit": "2.47.0.1"
}
diff --git a/go.mod b/go.mod
index cc34f23f2..0bc355d71 100644
--- a/go.mod
+++ b/go.mod
@@ -242,7 +242,7 @@ require (
go.opentelemetry.io/otel/trace v1.25.0 // indirect
go.opentelemetry.io/proto/otlp v1.1.0 // indirect
go.uber.org/mock v0.5.0 // indirect
- golang.org/x/net v0.29.0 // indirect
+ golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
diff --git a/go.sum b/go.sum
index 9a98a81e3..e5855879f 100644
--- a/go.sum
+++ b/go.sum
@@ -723,8 +723,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
-golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
-golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
From 7001f8e088dc1acb736c2ab7de08c458b22cbe81 Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Wed, 5 Feb 2025 15:20:20 +1300
Subject: [PATCH 008/212] fix(edge): check all endpoint_relation db query logic
[BE-11602] (#378)
---
api/http/handler/edgegroups/edgegroup_update.go | 1 +
api/http/handler/edgestacks/edgestack_update.go | 13 +++++++++++--
.../endpointedge/endpointedge_status_inspect.go | 3 +++
api/http/handler/endpointgroups/endpoints.go | 12 +++++++++++-
api/http/handler/endpoints/update_edge_relations.go | 1 +
api/http/handler/tags/tag_delete.go | 10 +++++++++-
api/internal/edge/edgestacks/service.go | 12 ++++++++++++
7 files changed, 48 insertions(+), 4 deletions(-)
diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go
index e67607d92..319a1b03b 100644
--- a/api/http/handler/edgegroups/edgegroup_update.go
+++ b/api/http/handler/edgegroups/edgegroup_update.go
@@ -186,6 +186,7 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi
if relation == nil {
relation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
+ EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
}
relation.EdgeStacks = edgeStackSet
diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go
index 9b5bb623d..593e403ef 100644
--- a/api/http/handler/edgestacks/edgestack_update.go
+++ b/api/http/handler/edgestacks/edgestack_update.go
@@ -107,7 +107,7 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, payload.DeploymentType)
if err != nil {
- return nil, httperror.BadRequest("unable to check for existence of non fitting environments: %w", err)
+ return nil, httperror.InternalServerError("unable to check for existence of non fitting environments: %w", err)
}
if hasWrongType {
return nil, httperror.BadRequest("edge stack with config do not match the environment type", nil)
@@ -151,6 +151,9 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
for endpointID := range endpointsToRemove {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
+ if tx.IsErrObjectNotFound(err) {
+ continue
+ }
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
}
@@ -170,10 +173,16 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
for endpointID := range endpointsToAdd {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
- if err != nil {
+ if err != nil && !tx.IsErrObjectNotFound(err) {
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
}
+ if relation == nil {
+ relation = &portainer.EndpointRelation{
+ EndpointID: endpointID,
+ EdgeStacks: map[portainer.EdgeStackID]bool{},
+ }
+ }
relation.EdgeStacks[edgeStackID] = true
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
diff --git a/api/http/handler/endpointedge/endpointedge_status_inspect.go b/api/http/handler/endpointedge/endpointedge_status_inspect.go
index 9bd341561..4d6368493 100644
--- a/api/http/handler/endpointedge/endpointedge_status_inspect.go
+++ b/api/http/handler/endpointedge/endpointedge_status_inspect.go
@@ -264,6 +264,9 @@ 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 b7c9771bb..b34032d9e 100644
--- a/api/http/handler/endpointgroups/endpoints.go
+++ b/api/http/handler/endpointgroups/endpoints.go
@@ -21,10 +21,17 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
}
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
- if err != nil {
+ if err != nil && !tx.IsErrObjectNotFound(err) {
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
@@ -32,6 +39,9 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
edgeStacks, err := tx.EdgeStack().EdgeStacks()
if err != nil {
+ if tx.IsErrObjectNotFound(err) {
+ return nil
+ }
return err
}
diff --git a/api/http/handler/endpoints/update_edge_relations.go b/api/http/handler/endpoints/update_edge_relations.go
index 8bebadedd..1390c9fd4 100644
--- a/api/http/handler/endpoints/update_edge_relations.go
+++ b/api/http/handler/endpoints/update_edge_relations.go
@@ -23,6 +23,7 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin
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")
diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go
index ad8ef1347..4f8554faf 100644
--- a/api/http/handler/tags/tag_delete.go
+++ b/api/http/handler/tags/tag_delete.go
@@ -133,10 +133,17 @@ 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 {
+ if err != nil && !tx.IsErrObjectNotFound(err) {
return err
}
+ if endpointRelation == nil {
+ endpointRelation = &portainer.EndpointRelation{
+ EndpointID: endpoint.ID,
+ EdgeStacks: make(map[portainer.EdgeStackID]bool),
+ }
+ }
+
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
if err != nil {
return err
@@ -147,6 +154,7 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End
for _, edgeStackID := range endpointStacks {
stacksSet[edgeStackID] = true
}
+
endpointRelation.EdgeStacks = stacksSet
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
diff --git a/api/internal/edge/edgestacks/service.go b/api/internal/edge/edgestacks/service.go
index 4fc6943af..3a19e8c9d 100644
--- a/api/internal/edge/edgestacks/service.go
+++ b/api/internal/edge/edgestacks/service.go
@@ -11,6 +11,7 @@ import (
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/internal/edge"
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
+ "github.com/rs/zerolog/log"
"github.com/pkg/errors"
)
@@ -119,6 +120,9 @@ 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)
}
@@ -147,6 +151,14 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
for _, endpointID := range relatedEndpointIds {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
+ if tx.IsErrObjectNotFound(err) {
+ log.Warn().
+ Int("endpoint_id", int(endpointID)).
+ Msg("Unable to find endpoint relation in database, skipping")
+
+ continue
+ }
+
return errors.WithMessage(err, "Unable to find environment relation in database")
}
From 5423a2f1b95a1daa118093d7c8408db2ca2af302 Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Wed, 5 Feb 2025 15:56:30 +1300
Subject: [PATCH 009/212] security: cve-2025-21613 develop (#390)
---
go.mod | 12 ++++++------
go.sum | 55 ++++++++++++++++++-------------------------------------
2 files changed, 24 insertions(+), 43 deletions(-)
diff --git a/go.mod b/go.mod
index 0bc355d71..053035ec1 100644
--- a/go.mod
+++ b/go.mod
@@ -21,7 +21,7 @@ require (
github.com/docker/docker v27.4.0+incompatible
github.com/fvbommel/sortorder v1.1.0
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
- github.com/go-git/go-git/v5 v5.11.0
+ github.com/go-git/go-git/v5 v5.13.0
github.com/go-ldap/ldap/v3 v3.4.1
github.com/go-playground/validator/v10 v10.12.0
github.com/gofrs/uuid v4.2.0+incompatible
@@ -71,7 +71,7 @@ require (
github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c // indirect
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
- github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect
+ github.com/ProtonMail/go-crypto v1.1.3 // 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
@@ -105,7 +105,7 @@ require (
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect
github.com/containers/ocicrypt v1.1.10 // indirect
github.com/containers/storage v1.53.0 // indirect
- github.com/cyphar/filepath-securejoin v0.2.4 // indirect
+ github.com/cyphar/filepath-securejoin v0.2.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/buildx v0.18.0 // indirect
@@ -125,7 +125,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
- github.com/go-git/go-billy/v5 v5.5.0 // indirect
+ github.com/go-git/go-billy/v5 v5.6.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
@@ -205,10 +205,10 @@ require (
github.com/rivo/uniseg v0.4.4 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
- github.com/sergi/go-diff v1.3.1 // indirect
+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
- github.com/skeema/knownhosts v1.2.1 // indirect
+ github.com/skeema/knownhosts v1.3.0 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
diff --git a/go.sum b/go.sum
index e5855879f..6427b69de 100644
--- a/go.sum
+++ b/go.sum
@@ -28,8 +28,8 @@ github.com/Microsoft/hcsshim v0.12.5 h1:bpTInLlDy/nDRWFVcefDZZ1+U8tS+rz3MxjKgu9b
github.com/Microsoft/hcsshim v0.12.5/go.mod h1:tIUGego4G1EN5Hb6KC90aDYiUI2dqLSTTOCjVNpOgZ8=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
-github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg=
-github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
+github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk=
+github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs=
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
@@ -99,7 +99,6 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembj
github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50=
github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o=
github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
-github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cbroglie/mustache v1.4.0 h1:Azg0dVhxTml5me+7PsZ7WPrQq1Gkf3WApcHMjMprYoU=
github.com/cbroglie/mustache v1.4.0/go.mod h1:SS1FTIghy0sjse4DUVGV1k/40B1qE1XkD9DtDsHo9iM=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -110,7 +109,6 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
github.com/cloudflare/cfssl v1.6.4 h1:NMOvfrEjFfC63K3SGXgAnFdsgkmiq4kATme5BfcqrO8=
github.com/cloudflare/cfssl v1.6.4/go.mod h1:8b3CQMxfWPAeom3zBnGJ6sd+G1NkL5TXqmDXacb+1J0=
-github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI=
@@ -163,8 +161,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
-github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
-github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
+github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
+github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -205,8 +203,8 @@ github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNE
github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM=
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg=
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg=
-github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
-github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
+github.com/elazarl/goproxy v1.2.1 h1:njjgvO6cRG9rIqN2ebkqy6cQz2Njkx7Fsfv/zIZqgug=
+github.com/elazarl/goproxy v1.2.1/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -227,18 +225,18 @@ github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQ
github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU=
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI=
-github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
-github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
+github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
+github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us009o8=
github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
-github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
-github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
+github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
+github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
-github.com/go-git/go-git/v5 v5.11.0 h1:XIZc1p+8YzypNr34itUfSvYJcv+eYdTnTvOZ2vD3cA4=
-github.com/go-git/go-git/v5 v5.11.0/go.mod h1:6GFcX2P3NM7FPBfpePbpLd21XxsgdAt+lKqXmCUiUCY=
+github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E=
+github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-ldap/ldap/v3 v3.4.1 h1:fU/0xli6HY02ocbMuozHAYsaHLcnkLjvho2r5a34BUU=
github.com/go-ldap/ldap/v3 v3.4.1/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg=
@@ -486,8 +484,8 @@ github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4
github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
-github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg=
-github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
+github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
+github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
@@ -559,8 +557,8 @@ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.3.6 h1:E6lVLyDPseWEulBmCmAKPanDd3jiyGDo5gMcugCRwZQ=
github.com/segmentio/encoding v0.3.6/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM=
-github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
-github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b h1:h+3JX2VoWTFuyQEo87pStk/a99dzIO1mM9KxIyLPGTU=
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b/go.mod h1:/yeG0My1xr/u+HZrFQ1tOQQQQrOawfyMUH13ai5brBc=
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
@@ -571,8 +569,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ=
-github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
+github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY=
+github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/spdx/tools-golang v0.5.3 h1:ialnHeEYUC4+hkm5vJm4qz2x+oEJbS0mAMFrNXdQraY=
@@ -697,8 +695,6 @@ 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.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
-golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
@@ -706,7 +702,6 @@ golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs
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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -720,9 +715,6 @@ 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.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
@@ -735,7 +727,6 @@ 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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -764,18 +755,11 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
-golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
-golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -783,8 +767,6 @@ 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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
@@ -794,7 +776,6 @@ 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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
From 371e84d9a559233d3a2edf59b7ebfa6c13d49c2c Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Wed, 5 Feb 2025 20:22:33 +1300
Subject: [PATCH 010/212] fix(podman): create new image from a container in
podman [r8s-90] (#347)
---
app/docker/helpers/imageHelper.js | 8 ++++++--
.../containers/edit/containerController.js | 4 ++--
.../views/images/edit/imageController.js | 4 +---
.../images/import/importImageController.js | 4 +---
app/react/docker/images/utils.test.ts | 18 ++++++++++++++++++
app/react/docker/images/utils.ts | 9 +++++++++
6 files changed, 37 insertions(+), 10 deletions(-)
diff --git a/app/docker/helpers/imageHelper.js b/app/docker/helpers/imageHelper.js
index 0bacd6c53..3ce3d4a45 100644
--- a/app/docker/helpers/imageHelper.js
+++ b/app/docker/helpers/imageHelper.js
@@ -1,4 +1,4 @@
-import { buildImageFullURIFromModel, imageContainsURL } from '@/react/docker/images/utils';
+import { buildImageFullURIFromModel, imageContainsURL, fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
angular.module('portainer.docker').factory('ImageHelper', ImageHelperFactory);
function ImageHelperFactory() {
@@ -18,8 +18,12 @@ function ImageHelperFactory() {
* @param {PorImageRegistryModel} registry
*/
function createImageConfigForContainer(imageModel) {
+ const fromImage = buildImageFullURIFromModel(imageModel);
+ const { tag, repo } = fullURIIntoRepoAndTag(fromImage);
return {
- fromImage: buildImageFullURIFromModel(imageModel),
+ fromImage,
+ tag,
+ repo,
};
}
diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js
index 085b2d73d..78f153e27 100644
--- a/app/docker/views/containers/edit/containerController.js
+++ b/app/docker/views/containers/edit/containerController.js
@@ -207,9 +207,9 @@ angular.module('portainer.docker').controller('ContainerController', [
async function commitContainerAsync() {
$scope.config.commitInProgress = true;
const registryModel = $scope.config.RegistryModel;
- const imageConfig = ImageHelper.createImageConfigForContainer(registryModel);
+ const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
try {
- await commitContainer(endpoint.Id, { container: $transition$.params().id, repo: imageConfig.fromImage });
+ await commitContainer(endpoint.Id, { container: $transition$.params().id, repo, tag });
Notifications.success('Image created', $transition$.params().id);
$state.reload();
} catch (err) {
diff --git a/app/docker/views/images/edit/imageController.js b/app/docker/views/images/edit/imageController.js
index 78ae6107c..8ed5c9495 100644
--- a/app/docker/views/images/edit/imageController.js
+++ b/app/docker/views/images/edit/imageController.js
@@ -2,7 +2,6 @@ import _ from 'lodash-es';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
import { confirmDelete } from '@@/modals/confirm';
-import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
angular.module('portainer.docker').controller('ImageController', [
'$async',
@@ -71,8 +70,7 @@ angular.module('portainer.docker').controller('ImageController', [
$scope.tagImage = function () {
const registryModel = $scope.formValues.RegistryModel;
- const image = ImageHelper.createImageConfigForContainer(registryModel);
- const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
+ const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
ImageService.tagImage($transition$.params().id, repo, tag)
.then(function success() {
diff --git a/app/docker/views/images/import/importImageController.js b/app/docker/views/images/import/importImageController.js
index dfdb8ab1a..d5587ecb9 100644
--- a/app/docker/views/images/import/importImageController.js
+++ b/app/docker/views/images/import/importImageController.js
@@ -1,5 +1,4 @@
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
-import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
angular.module('portainer.docker').controller('ImportImageController', [
'$scope',
@@ -34,8 +33,7 @@ angular.module('portainer.docker').controller('ImportImageController', [
async function tagImage(id) {
const registryModel = $scope.formValues.RegistryModel;
if (registryModel.Image) {
- const image = ImageHelper.createImageConfigForContainer(registryModel);
- const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
+ const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
try {
await ImageService.tagImage(id, repo, tag);
} catch (err) {
diff --git a/app/react/docker/images/utils.test.ts b/app/react/docker/images/utils.test.ts
index 05f0e39ff..70e1cf74c 100644
--- a/app/react/docker/images/utils.test.ts
+++ b/app/react/docker/images/utils.test.ts
@@ -16,6 +16,24 @@ describe('fullURIIntoRepoAndTag', () => {
expect(result).toEqual({ repo: 'nginx', tag: 'latest' });
});
+ it('splits image-repo:port/image correctly', () => {
+ const result = fullURIIntoRepoAndTag('registry.example.com:5000/my-image');
+ expect(result).toEqual({
+ repo: 'registry.example.com:5000/my-image',
+ tag: 'latest',
+ });
+ });
+
+ it('splits image-repo:port/image:tag correctly', () => {
+ const result = fullURIIntoRepoAndTag(
+ 'registry.example.com:5000/my-image:v1'
+ );
+ expect(result).toEqual({
+ repo: 'registry.example.com:5000/my-image',
+ tag: 'v1',
+ });
+ });
+
it('splits registry:port/image-repo:tag correctly', () => {
const result = fullURIIntoRepoAndTag(
'registry.example.com:5000/my-image:v2.1'
diff --git a/app/react/docker/images/utils.ts b/app/react/docker/images/utils.ts
index 10d6ae0e9..e9aeaf661 100644
--- a/app/react/docker/images/utils.ts
+++ b/app/react/docker/images/utils.ts
@@ -121,9 +121,18 @@ export function fullURIIntoRepoAndTag(fullURI: string) {
// - registry/image-repo:tag
// - image-repo:tag
// - registry:port/image-repo:tag
+ // - localhost:5000/nginx
// buildImageFullURIFromModel always gives a tag (defaulting to 'latest'), so the tag is always present after the last ':'
const parts = fullURI.split(':');
const tag = parts.pop() || 'latest';
+
+ // handle the case of a repo with a non standard port
+ if (tag.includes('/')) {
+ return {
+ repo: fullURI,
+ tag: 'latest',
+ };
+ }
const repo = parts.join(':');
return {
repo,
From 9658f757c2c37aa99d88bb047cd0b76400a4fd88 Mon Sep 17 00:00:00 2001
From: viktigpetterr
Date: Thu, 6 Feb 2025 18:14:43 +0100
Subject: [PATCH 011/212] fix(endpoints): use the post method for batch delete
API operations [BE-11573] (#394)
---
api/http/handler/endpoints/endpoint_delete.go | 23 ++++++++++++++++++-
api/http/handler/endpoints/handler.go | 5 ++--
.../ListView/useDeleteEnvironmentsMutation.ts | 6 ++---
3 files changed, 28 insertions(+), 6 deletions(-)
diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go
index 0add2ccdf..4752364ec 100644
--- a/api/http/handler/endpoints/endpoint_delete.go
+++ b/api/http/handler/endpoints/endpoint_delete.go
@@ -91,7 +91,7 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to delete the specified environments."
-// @router /endpoints [delete]
+// @router /endpoints/delete [post]
func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var p endpointDeleteBatchPayload
if err := request.DecodeAndValidateJSONPayload(r, &p); err != nil {
@@ -127,6 +127,27 @@ func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Reque
return response.Empty(w)
}
+// @id EndpointDeleteBatchDeprecated
+// @summary Remove multiple environments
+// @deprecated
+// @description Deprecated: use the `POST` endpoint instead.
+// @description Remove multiple environments and optionally clean-up associated resources.
+// @description **Access policy**: Administrator only.
+// @tags endpoints
+// @security ApiKeyAuth || jwt
+// @accept json
+// @produce json
+// @param body body endpointDeleteBatchPayload true "List of environments to delete, with optional deleteCluster flag to clean-up associated resources (cloud environments only)"
+// @success 204 "Environment(s) successfully deleted."
+// @failure 207 {object} endpointDeleteBatchPartialResponse "Partial success. Some environments were deleted successfully, while others failed."
+// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
+// @failure 403 "Unauthorized access or operation not allowed."
+// @failure 500 "Server error occurred while attempting to delete the specified environments."
+// @router /endpoints [delete]
+func (handler *Handler) endpointDeleteBatchDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+ return handler.endpointDeleteBatch(w, r)
+}
+
func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error {
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if tx.IsErrObjectNotFound(err) {
diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go
index fa852633c..d1e323e8f 100644
--- a/api/http/handler/endpoints/handler.go
+++ b/api/http/handler/endpoints/handler.go
@@ -68,8 +68,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
- h.Handle("/endpoints",
- bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodDelete)
+ h.Handle("/endpoints/delete",
+ bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/snapshot",
@@ -85,6 +85,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
// DEPRECATED
h.Handle("/endpoints/{id}/status", bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet)
+ h.Handle("/endpoints", bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatchDeprecated))).Methods(http.MethodDelete)
return h
}
diff --git a/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts b/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts
index 01a87681f..48f539f11 100644
--- a/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts
+++ b/app/react/portainer/environments/ListView/useDeleteEnvironmentsMutation.ts
@@ -55,11 +55,11 @@ async function deleteEnvironments(
environments: { id: EnvironmentId; deleteCluster?: boolean }[]
) {
try {
- const { data } = await axios.delete<{
+ const { data } = await axios.post<{
deleted: EnvironmentId[];
errors: EnvironmentId[];
- } | null>(buildUrl(), {
- data: { endpoints: environments },
+ } | null>(buildUrl(undefined, 'delete'), {
+ endpoints: environments,
});
return data;
} catch (e) {
From d4e2b2188ed6c4b72bc28e3a1b821088eae5e07d Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Fri, 7 Feb 2025 08:48:19 +1300
Subject: [PATCH 012/212] chore: bump go version to 1.23.5 develop (#392)
---
go.mod | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/go.mod b/go.mod
index 053035ec1..8f39fe3a9 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/portainer/portainer
-go 1.23.2
+go 1.23.5
require (
github.com/Masterminds/semver v1.5.0
From 1b2dc6a133ddc4fd300742e46680354a62f8bc8b Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Fri, 7 Feb 2025 14:47:49 +1300
Subject: [PATCH 013/212] version: bump version to 2.27.0-rc2 - develop (#402)
---
api/datastore/test_data/output_24_to_latest.json | 4 ++--
api/portainer.go | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index 9fa3e5f09..43d872914 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -610,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
- "KubectlShellImage": "portainer/kubectl-shell:2.27.0-rc1",
+ "KubectlShellImage": "portainer/kubectl-shell:2.27.0-rc2",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
- "VERSION": "{\"SchemaVersion\":\"2.27.0-rc1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
+ "VERSION": "{\"SchemaVersion\":\"2.27.0-rc2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
\ No newline at end of file
diff --git a/api/portainer.go b/api/portainer.go
index ad0989437..64ac47012 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -1636,7 +1636,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
- APIVersion = "2.27.0-rc1"
+ APIVersion = "2.27.0-rc2"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
// Edition is what this edition of Portainer is called
From 935c7dd49650727b0ded15016b59a0829f3269e0 Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Mon, 10 Feb 2025 10:45:47 +1300
Subject: [PATCH 014/212] feat: improve diagnostics stability - develop (#355)
---
pkg/endpoints/utils.go | 14 ++++++++
pkg/endpoints/utils_test.go | 58 +++++++++++++++++++++++++++++++++
pkg/snapshot/docker_test.go | 1 -
pkg/snapshot/kubernetes_test.go | 43 ++++++++++++++++++++++++
4 files changed, 115 insertions(+), 1 deletion(-)
delete mode 100644 pkg/snapshot/docker_test.go
diff --git a/pkg/endpoints/utils.go b/pkg/endpoints/utils.go
index e32b56fea..4c704d86f 100644
--- a/pkg/endpoints/utils.go
+++ b/pkg/endpoints/utils.go
@@ -1,6 +1,7 @@
package endpoints
import (
+ "github.com/hashicorp/go-version"
portainer "github.com/portainer/portainer/api"
)
@@ -15,6 +16,11 @@ func IsEdgeEndpoint(endpoint *portainer.Endpoint) bool {
return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
}
+// IsStandardEdgeEndpoint returns true if this is a standard Edge endpoint and not in async mode on either Docker or Kubernetes
+func IsStandardEdgeEndpoint(endpoint *portainer.Endpoint) bool {
+ return (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) && !endpoint.Edge.AsyncMode
+}
+
// IsAssociatedEdgeEndpoint returns true if the environment is an Edge environment
// and has a set EdgeID and UserTrusted is true.
func IsAssociatedEdgeEndpoint(endpoint *portainer.Endpoint) bool {
@@ -26,3 +32,11 @@ func IsAssociatedEdgeEndpoint(endpoint *portainer.Endpoint) bool {
func HasDirectConnectivity(endpoint *portainer.Endpoint) bool {
return !IsEdgeEndpoint(endpoint) || (IsAssociatedEdgeEndpoint(endpoint) && !endpoint.Edge.AsyncMode)
}
+
+// IsNewerThan225 returns true if the agent version is newer than 2.25.0
+// this is used to check if the agent is compatible with the new diagnostics feature
+func IsNewerThan225(agentVersion string) bool {
+ v1, _ := version.NewVersion(agentVersion)
+ v2, _ := version.NewVersion("2.25.0")
+ return v1.GreaterThanOrEqual(v2)
+}
diff --git a/pkg/endpoints/utils_test.go b/pkg/endpoints/utils_test.go
index 47ef2db8d..4231cf60a 100644
--- a/pkg/endpoints/utils_test.go
+++ b/pkg/endpoints/utils_test.go
@@ -202,3 +202,61 @@ func TestHasDirectConnectivity(t *testing.T) {
})
}
}
+
+func TestIsStandardEdgeEndpoint(t *testing.T) {
+ tests := []struct {
+ name string
+ endpoint *portainer.Endpoint
+ expected bool
+ }{
+ {
+ name: "StandardEdgeEndpoint",
+ endpoint: &portainer.Endpoint{
+ Type: portainer.EdgeAgentOnDockerEnvironment,
+ Edge: portainer.EnvironmentEdgeSettings{AsyncMode: false},
+ },
+ expected: true,
+ },
+ {
+ name: "AsyncEdgeEndpoint",
+ endpoint: &portainer.Endpoint{
+ Type: portainer.EdgeAgentOnDockerEnvironment,
+ Edge: portainer.EnvironmentEdgeSettings{AsyncMode: true},
+ },
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := IsStandardEdgeEndpoint(tt.endpoint)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestIsNewerThan225(t *testing.T) {
+ tests := []struct {
+ name string
+ version string
+ expected bool
+ }{
+ {
+ name: "NewerThan225",
+ version: "2.25.1",
+ expected: true,
+ },
+ {
+ name: "OlderThan225",
+ version: "2.24.0",
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := IsNewerThan225(tt.version)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
diff --git a/pkg/snapshot/docker_test.go b/pkg/snapshot/docker_test.go
deleted file mode 100644
index 8df14bc39..000000000
--- a/pkg/snapshot/docker_test.go
+++ /dev/null
@@ -1 +0,0 @@
-package snapshot
diff --git a/pkg/snapshot/kubernetes_test.go b/pkg/snapshot/kubernetes_test.go
index 8df14bc39..142241a5c 100644
--- a/pkg/snapshot/kubernetes_test.go
+++ b/pkg/snapshot/kubernetes_test.go
@@ -1 +1,44 @@
package snapshot
+
+import (
+ "context"
+ "testing"
+
+ portainer "github.com/portainer/portainer/api"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ kfake "k8s.io/client-go/kubernetes/fake"
+)
+
+func TestCreateKubernetesSnapshot(t *testing.T) {
+ cli := kfake.NewSimpleClientset()
+ kubernetesSnapshot := &portainer.KubernetesSnapshot{}
+
+ serverInfo, err := cli.Discovery().ServerVersion()
+ if err != nil {
+ t.Fatalf("error getting the kubernetesserver version: %v", err)
+ }
+
+ kubernetesSnapshot.KubernetesVersion = serverInfo.GitVersion
+ require.Equal(t, kubernetesSnapshot.KubernetesVersion, serverInfo.GitVersion)
+
+ nodeList, err := cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
+ if err != nil {
+ t.Fatalf("error listing kubernetes nodes: %v", err)
+ }
+
+ var totalCPUs, totalMemory int64
+ for _, node := range nodeList.Items {
+ totalCPUs += node.Status.Capacity.Cpu().Value()
+ totalMemory += node.Status.Capacity.Memory().Value()
+ }
+
+ kubernetesSnapshot.TotalCPU = totalCPUs
+ kubernetesSnapshot.TotalMemory = totalMemory
+ kubernetesSnapshot.NodeCount = len(nodeList.Items)
+ require.Equal(t, kubernetesSnapshot.TotalCPU, totalCPUs)
+ require.Equal(t, kubernetesSnapshot.TotalMemory, totalMemory)
+ require.Equal(t, kubernetesSnapshot.NodeCount, len(nodeList.Items))
+
+ t.Logf("Kubernetes snapshot: %+v", kubernetesSnapshot)
+}
From 03575186a7966ab3ca5014e24b5b3e7280d077bc Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Mon, 10 Feb 2025 10:46:36 +1300
Subject: [PATCH 015/212] remove deprecated api endpoints - develop [BE-11510]
(#399)
---
.../customtemplates/customtemplate_create.go | 25 ----
api/http/handler/customtemplates/handler.go | 2 -
api/http/handler/edgejobs/edgejob_create.go | 23 ----
api/http/handler/edgejobs/handler.go | 3 -
.../handler/edgestacks/edgestack_create.go | 23 ----
.../edgestacks/edgestack_status_delete.go | 87 ------------
.../edgestack_status_delete_test.go | 30 -----
api/http/handler/edgestacks/handler.go | 4 -
.../edgetemplates/edgetemplate_list.go | 71 ----------
api/http/handler/edgetemplates/handler.go | 32 -----
api/http/handler/handler.go | 4 -
api/http/handler/helm/handler.go | 6 -
api/http/handler/helm/user_helm_repos.go | 127 ------------------
api/http/handler/stacks/handler.go | 3 -
api/http/handler/stacks/stack_create.go | 51 -------
api/http/handler/system/handler.go | 4 -
api/http/handler/system/nodes_count.go | 20 ---
api/http/handler/system/version.go | 18 ---
api/http/handler/templates/handler.go | 2 -
.../handler/templates/template_file_old.go | 93 -------------
api/http/server.go | 5 -
app/constants.ts | 1 -
app/ng-constants.ts | 2 -
23 files changed, 636 deletions(-)
delete mode 100644 api/http/handler/edgestacks/edgestack_status_delete.go
delete mode 100644 api/http/handler/edgestacks/edgestack_status_delete_test.go
delete mode 100644 api/http/handler/edgetemplates/edgetemplate_list.go
delete mode 100644 api/http/handler/edgetemplates/handler.go
delete mode 100644 api/http/handler/helm/user_helm_repos.go
delete mode 100644 api/http/handler/templates/template_file_old.go
diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go
index a5b6ecdee..0f9c8f1a3 100644
--- a/api/http/handler/customtemplates/customtemplate_create.go
+++ b/api/http/handler/customtemplates/customtemplate_create.go
@@ -482,28 +482,3 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
return customTemplate, nil
}
-
-// @id CustomTemplateCreate
-// @summary Create a custom template
-// @description Create a custom template.
-// @description **Access policy**: authenticated
-// @tags custom_templates
-// @security ApiKeyAuth
-// @security jwt
-// @accept json,multipart/form-data
-// @produce json
-// @param method query string true "method for creating template" Enums(string, file, repository)
-// @param body body object true "for body documentation see the relevant /custom_templates/{method} endpoint"
-// @success 200 {object} portainer.CustomTemplate
-// @failure 400 "Invalid request"
-// @failure 500 "Server error"
-// @deprecated
-// @router /custom_templates [post]
-func deprecatedCustomTemplateCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
- method, err := request.RetrieveQueryParameter(r, "method", false)
- if err != nil {
- return "", httperror.BadRequest("Invalid query parameter: method", err)
- }
-
- return "/custom_templates/create/" + method, nil
-}
diff --git a/api/http/handler/customtemplates/handler.go b/api/http/handler/customtemplates/handler.go
index 1bb148af6..0da63d81f 100644
--- a/api/http/handler/customtemplates/handler.go
+++ b/api/http/handler/customtemplates/handler.go
@@ -7,7 +7,6 @@ import (
"github.com/gorilla/mux"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
- "github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
@@ -33,7 +32,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/custom_templates/create/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost)
- h.Handle("/custom_templates", middlewares.Deprecated(h, deprecatedCustomTemplateCreateUrlParser)).Methods(http.MethodPost) // Deprecated
h.Handle("/custom_templates",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet)
h.Handle("/custom_templates/{id}",
diff --git a/api/http/handler/edgejobs/edgejob_create.go b/api/http/handler/edgejobs/edgejob_create.go
index 252770aa2..0f7bff73d 100644
--- a/api/http/handler/edgejobs/edgejob_create.go
+++ b/api/http/handler/edgejobs/edgejob_create.go
@@ -271,26 +271,3 @@ func (handler *Handler) addAndPersistEdgeJob(tx dataservices.DataStoreTx, edgeJo
return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob)
}
-
-// @id EdgeJobCreate
-// @summary Create an EdgeJob
-// @description **Access policy**: administrator
-// @tags edge_jobs
-// @security ApiKeyAuth
-// @security jwt
-// @produce json
-// @param method query string true "Creation Method" Enums(file, string)
-// @param body body object true "for body documentation see the relevant /edge_jobs/create/{method} endpoint"
-// @success 200 {object} portainer.EdgeGroup
-// @failure 503 "Edge compute features are disabled"
-// @failure 500
-// @deprecated
-// @router /edge_jobs [post]
-func deprecatedEdgeJobCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
- method, err := request.RetrieveQueryParameter(r, "method", false)
- if err != nil {
- return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
- }
-
- return "/edge_jobs/create/" + method, nil
-}
diff --git a/api/http/handler/edgejobs/handler.go b/api/http/handler/edgejobs/handler.go
index 93f210bb5..ab3d66b3b 100644
--- a/api/http/handler/edgejobs/handler.go
+++ b/api/http/handler/edgejobs/handler.go
@@ -6,7 +6,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
- "github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -30,8 +29,6 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/edge_jobs",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
- h.Handle("/edge_jobs",
- bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeJobCreateUrlParser)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/{id}",
diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go
index 12dfe620a..65e77764a 100644
--- a/api/http/handler/edgestacks/edgestack_create.go
+++ b/api/http/handler/edgestacks/edgestack_create.go
@@ -55,26 +55,3 @@ func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method str
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
}
-
-// @id EdgeStackCreate
-// @summary Create an EdgeStack
-// @description **Access policy**: administrator
-// @tags edge_stacks
-// @security ApiKeyAuth
-// @security jwt
-// @produce json
-// @param method query string true "Creation Method" Enums(file,string,repository)
-// @param body body object true "for body documentation see the relevant /edge_stacks/create/{method} endpoint"
-// @success 200 {object} portainer.EdgeStack
-// @failure 500
-// @failure 503 "Edge compute features are disabled"
-// @deprecated
-// @router /edge_stacks [post]
-func deprecatedEdgeStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
- method, err := request.RetrieveQueryParameter(r, "method", false)
- if err != nil {
- return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
- }
-
- return "/edge_stacks/create/" + method, nil
-}
diff --git a/api/http/handler/edgestacks/edgestack_status_delete.go b/api/http/handler/edgestacks/edgestack_status_delete.go
deleted file mode 100644
index eb54951b6..000000000
--- a/api/http/handler/edgestacks/edgestack_status_delete.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package edgestacks
-
-import (
- "errors"
- "net/http"
- "time"
-
- portainer "github.com/portainer/portainer/api"
- "github.com/portainer/portainer/api/dataservices"
- "github.com/portainer/portainer/api/http/middlewares"
- httperror "github.com/portainer/portainer/pkg/libhttp/error"
- "github.com/portainer/portainer/pkg/libhttp/request"
- "github.com/portainer/portainer/pkg/libhttp/response"
-)
-
-// @id EdgeStackStatusDelete
-// @summary Delete an EdgeStack status
-// @description Authorized only if the request is done by an Edge Environment(Endpoint)
-// @tags edge_stacks
-// @produce json
-// @param id path int true "EdgeStack Id"
-// @param environmentId path int true "Environment identifier"
-// @success 200 {object} portainer.EdgeStack
-// @failure 500
-// @failure 400
-// @failure 404
-// @failure 403
-// @deprecated
-// @router /edge_stacks/{id}/status/{environmentId} [delete]
-func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
- if err != nil {
- return httperror.BadRequest("Invalid stack identifier route variable", err)
- }
-
- endpoint, err := middlewares.FetchEndpoint(r)
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve a valid endpoint from the handler context", err)
- }
-
- err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
- if err != nil {
- return httperror.Forbidden("Permission denied to access environment", err)
- }
-
- var stack *portainer.EdgeStack
- err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
- stack, err = handler.deleteEdgeStackStatus(tx, portainer.EdgeStackID(stackID), endpoint)
- return err
- })
- if err != nil {
- var httpErr *httperror.HandlerError
- if errors.As(err, &httpErr) {
- return httpErr
- }
-
- return httperror.InternalServerError("Unexpected error", err)
- }
-
- return response.JSON(w, stack)
-}
-
-func (handler *Handler) deleteEdgeStackStatus(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, endpoint *portainer.Endpoint) (*portainer.EdgeStack, error) {
- stack, err := tx.EdgeStack().EdgeStack(stackID)
- if err != nil {
- return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
- }
-
- environmentStatus, ok := stack.Status[endpoint.ID]
- if !ok {
- environmentStatus = portainer.EdgeStackStatus{}
- }
-
- environmentStatus.Status = append(environmentStatus.Status, portainer.EdgeStackDeploymentStatus{
- Time: time.Now().Unix(),
- Type: portainer.EdgeStackStatusRemoved,
- })
-
- stack.Status[endpoint.ID] = environmentStatus
-
- err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
- if err != nil {
- return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
- }
-
- return stack, nil
-}
diff --git a/api/http/handler/edgestacks/edgestack_status_delete_test.go b/api/http/handler/edgestacks/edgestack_status_delete_test.go
deleted file mode 100644
index 7a3db1315..000000000
--- a/api/http/handler/edgestacks/edgestack_status_delete_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package edgestacks
-
-import (
- "fmt"
- "net/http"
- "net/http/httptest"
- "testing"
-
- portainer "github.com/portainer/portainer/api"
-)
-
-func TestDeleteStatus(t *testing.T) {
- handler, _ := setupHandler(t)
-
- endpoint := createEndpoint(t, handler.DataStore)
- edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
-
- req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d/status/%d", edgeStack.ID, endpoint.ID), nil)
- if err != nil {
- t.Fatal("request error:", err)
- }
-
- req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID)
- rec := httptest.NewRecorder()
- handler.ServeHTTP(rec, req)
-
- if rec.Code != http.StatusOK {
- t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code)
- }
-}
diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go
index 290524cb0..9fa90776f 100644
--- a/api/http/handler/edgestacks/handler.go
+++ b/api/http/handler/edgestacks/handler.go
@@ -37,8 +37,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/edge_stacks/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
- h.Handle("/edge_stacks",
- bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeStackCreateUrlParser)))).Methods(http.MethodPost) // Deprecated
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
h.Handle("/edge_stacks/{id}",
@@ -55,8 +53,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
edgeStackStatusRouter := h.NewRoute().Subrouter()
edgeStackStatusRouter.Use(middlewares.WithEndpoint(h.DataStore.Endpoint(), "endpoint_id"))
- edgeStackStatusRouter.PathPrefix("/edge_stacks/{id}/status/{endpoint_id}").Handler(bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusDelete))).Methods(http.MethodDelete)
-
return h
}
diff --git a/api/http/handler/edgetemplates/edgetemplate_list.go b/api/http/handler/edgetemplates/edgetemplate_list.go
deleted file mode 100644
index 5b2c7254d..000000000
--- a/api/http/handler/edgetemplates/edgetemplate_list.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package edgetemplates
-
-import (
- "net/http"
- "slices"
-
- portainer "github.com/portainer/portainer/api"
- "github.com/portainer/portainer/api/http/client"
- httperror "github.com/portainer/portainer/pkg/libhttp/error"
- "github.com/portainer/portainer/pkg/libhttp/response"
-
- "github.com/segmentio/encoding/json"
-)
-
-type templateFileFormat struct {
- Version string `json:"version"`
- Templates []portainer.Template `json:"templates"`
-}
-
-// @id EdgeTemplateList
-// @deprecated
-// @summary Fetches the list of Edge Templates
-// @description **Access policy**: administrator
-// @tags edge_templates
-// @security ApiKeyAuth
-// @security jwt
-// @accept json
-// @produce json
-// @success 200 {array} portainer.Template
-// @failure 500
-// @router /edge_templates [get]
-func (handler *Handler) edgeTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- settings, err := handler.DataStore.Settings().Settings()
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve settings from the database", err)
- }
-
- url := portainer.DefaultTemplatesURL
- if settings.TemplatesURL != "" {
- url = settings.TemplatesURL
- }
-
- var templateData []byte
- templateData, err = client.Get(url, 10)
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve external templates", err)
- }
-
- var templateFile templateFileFormat
-
- err = json.Unmarshal(templateData, &templateFile)
- if err != nil {
- return httperror.InternalServerError("Unable to parse template file", err)
- }
-
- // We only support version 3 of the template format
- // this is only a temporary fix until we have custom edge templates
- if templateFile.Version != "3" {
- return httperror.InternalServerError("Unsupported template version", nil)
- }
-
- filteredTemplates := make([]portainer.Template, 0)
-
- for _, template := range templateFile.Templates {
- if slices.Contains(template.Categories, "edge") && slices.Contains([]portainer.TemplateType{portainer.ComposeStackTemplate, portainer.SwarmStackTemplate}, template.Type) {
- filteredTemplates = append(filteredTemplates, template)
- }
- }
-
- return response.JSON(w, filteredTemplates)
-}
diff --git a/api/http/handler/edgetemplates/handler.go b/api/http/handler/edgetemplates/handler.go
deleted file mode 100644
index d6c98553f..000000000
--- a/api/http/handler/edgetemplates/handler.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package edgetemplates
-
-import (
- "net/http"
-
- "github.com/portainer/portainer/api/dataservices"
- "github.com/portainer/portainer/api/http/middlewares"
- "github.com/portainer/portainer/api/http/security"
- httperror "github.com/portainer/portainer/pkg/libhttp/error"
-
- "github.com/gorilla/mux"
-)
-
-// Handler is the HTTP handler used to handle edge environment(endpoint) operations.
-type Handler struct {
- *mux.Router
- requestBouncer security.BouncerService
- DataStore dataservices.DataStore
-}
-
-// NewHandler creates a handler to manage environment(endpoint) operations.
-func NewHandler(bouncer security.BouncerService) *Handler {
- h := &Handler{
- Router: mux.NewRouter(),
- requestBouncer: bouncer,
- }
-
- h.Handle("/edge_templates",
- bouncer.AdminAccess(middlewares.Deprecated(httperror.LoggerHandler(h.edgeTemplateList), func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) { return "", nil }))).Methods(http.MethodGet)
-
- return h
-}
diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go
index 4d7973170..4325d9f91 100644
--- a/api/http/handler/handler.go
+++ b/api/http/handler/handler.go
@@ -11,7 +11,6 @@ import (
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks"
- "github.com/portainer/portainer/api/http/handler/edgetemplates"
"github.com/portainer/portainer/api/http/handler/endpointedge"
"github.com/portainer/portainer/api/http/handler/endpointgroups"
"github.com/portainer/portainer/api/http/handler/endpointproxy"
@@ -50,7 +49,6 @@ type Handler struct {
EdgeGroupsHandler *edgegroups.Handler
EdgeJobsHandler *edgejobs.Handler
EdgeStacksHandler *edgestacks.Handler
- EdgeTemplatesHandler *edgetemplates.Handler
EndpointEdgeHandler *endpointedge.Handler
EndpointGroupHandler *endpointgroups.Handler
EndpointHandler *endpoints.Handler
@@ -190,8 +188,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.EdgeGroupsHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/edge_jobs"):
http.StripPrefix("/api", h.EdgeJobsHandler).ServeHTTP(w, r)
- case strings.HasPrefix(r.URL.Path, "/api/edge_templates"):
- http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):
diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go
index 0108f7ef1..fb941940b 100644
--- a/api/http/handler/helm/handler.go
+++ b/api/http/handler/helm/handler.go
@@ -53,12 +53,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/{id}/kubernetes/helm",
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
- // Deprecated
- h.Handle("/{id}/kubernetes/helm/repositories",
- httperror.LoggerHandler(h.userGetHelmRepos)).Methods(http.MethodGet)
- h.Handle("/{id}/kubernetes/helm/repositories",
- httperror.LoggerHandler(h.userCreateHelmRepo)).Methods(http.MethodPost)
-
return h
}
diff --git a/api/http/handler/helm/user_helm_repos.go b/api/http/handler/helm/user_helm_repos.go
deleted file mode 100644
index 5197f88f9..000000000
--- a/api/http/handler/helm/user_helm_repos.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package helm
-
-import (
- "net/http"
- "strings"
-
- portainer "github.com/portainer/portainer/api"
- "github.com/portainer/portainer/api/http/security"
- "github.com/portainer/portainer/pkg/libhelm"
- httperror "github.com/portainer/portainer/pkg/libhttp/error"
- "github.com/portainer/portainer/pkg/libhttp/request"
- "github.com/portainer/portainer/pkg/libhttp/response"
-
- "github.com/pkg/errors"
-)
-
-type helmUserRepositoryResponse struct {
- GlobalRepository string `json:"GlobalRepository"`
- UserRepositories []portainer.HelmUserRepository `json:"UserRepositories"`
-}
-
-type addHelmRepoUrlPayload struct {
- URL string `json:"url"`
-}
-
-func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error {
- return libhelm.ValidateHelmRepositoryURL(p.URL, nil)
-}
-
-// @id HelmUserRepositoryCreateDeprecated
-// @summary Create a user helm repository
-// @description Create a user helm repository.
-// @description **Access policy**: authenticated
-// @tags helm
-// @security ApiKeyAuth
-// @security jwt
-// @accept json
-// @produce json
-// @param id path int true "Environment(Endpoint) identifier"
-// @param payload body addHelmRepoUrlPayload true "Helm Repository"
-// @success 200 {object} portainer.HelmUserRepository "Success"
-// @failure 400 "Invalid request"
-// @failure 403 "Permission denied"
-// @failure 500 "Server error"
-// @deprecated
-// @router /endpoints/{id}/kubernetes/helm/repositories [post]
-func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- tokenData, err := security.RetrieveTokenData(r)
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve user authentication token", err)
- }
- userID := tokenData.ID
-
- p := new(addHelmRepoUrlPayload)
- err = request.DecodeAndValidateJSONPayload(r, p)
- if err != nil {
- return httperror.BadRequest("Invalid Helm repository URL", err)
- }
-
- // lowercase, remove trailing slash
- p.URL = strings.TrimSuffix(strings.ToLower(p.URL), "/")
-
- records, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
- if err != nil {
- return httperror.InternalServerError("Unable to access the DataStore", err)
- }
-
- // check if repo already exists - by doing case insensitive comparison
- for _, record := range records {
- if strings.EqualFold(record.URL, p.URL) {
- errMsg := "Helm repo already registered for user"
- return httperror.BadRequest(errMsg, errors.New(errMsg))
- }
- }
-
- record := portainer.HelmUserRepository{
- UserID: userID,
- URL: p.URL,
- }
-
- err = handler.dataStore.HelmUserRepository().Create(&record)
- if err != nil {
- return httperror.InternalServerError("Unable to save a user Helm repository URL", err)
- }
-
- return response.JSON(w, record)
-}
-
-// @id HelmUserRepositoriesListDeprecated
-// @summary List a users helm repositories
-// @description Inspect a user helm repositories.
-// @description **Access policy**: authenticated
-// @tags helm
-// @security ApiKeyAuth
-// @security jwt
-// @produce json
-// @param id path int true "User identifier"
-// @success 200 {object} helmUserRepositoryResponse "Success"
-// @failure 400 "Invalid request"
-// @failure 403 "Permission denied"
-// @failure 500 "Server error"
-// @deprecated
-// @router /endpoints/{id}/kubernetes/helm/repositories [get]
-func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- tokenData, err := security.RetrieveTokenData(r)
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve user authentication token", err)
- }
- userID := tokenData.ID
-
- settings, err := handler.dataStore.Settings().Settings()
- if err != nil {
- return httperror.InternalServerError("Unable to retrieve settings from the database", err)
- }
-
- userRepos, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
- if err != nil {
- return httperror.InternalServerError("Unable to get user Helm repositories", err)
- }
-
- resp := helmUserRepositoryResponse{
- GlobalRepository: settings.HelmRepositoryURL,
- UserRepositories: userRepos,
- }
-
- return response.JSON(w, resp)
-}
diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go
index 7e5cce040..2ad40a182 100644
--- a/api/http/handler/stacks/handler.go
+++ b/api/http/handler/stacks/handler.go
@@ -11,7 +11,6 @@ import (
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/docker/consts"
- "github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -62,8 +61,6 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/stacks/create/{type}/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost)
- h.Handle("/stacks",
- bouncer.AuthenticatedAccess(middlewares.Deprecated(h, deprecatedStackCreateUrlParser))).Methods(http.MethodPost) // Deprecated
h.Handle("/stacks",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet)
h.Handle("/stacks/{id}",
diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go
index f45592d09..cb297f9d7 100644
--- a/api/http/handler/stacks/stack_create.go
+++ b/api/http/handler/stacks/stack_create.go
@@ -1,7 +1,6 @@
package stacks
import (
- "fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -141,53 +140,3 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
return response.JSON(w, stack)
}
-
-func getStackTypeFromQueryParameter(r *http.Request) (string, error) {
- stackType, err := request.RetrieveNumericQueryParameter(r, "type", false)
- if err != nil {
- return "", err
- }
-
- switch stackType {
- case 1:
- return "swarm", nil
- case 2:
- return "standalone", nil
- case 3:
- return "kubernetes", nil
- }
-
- return "", errors.New(request.ErrInvalidQueryParameter)
-}
-
-// @id StackCreate
-// @summary Deploy a new stack
-// @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier.
-// @description **Access policy**: authenticated
-// @tags stacks
-// @security ApiKeyAuth
-// @security jwt
-// @accept json,multipart/form-data
-// @produce json
-// @param type query int true "Stack deployment type. Possible values: 1 (Swarm stack), 2 (Compose stack) or 3 (Kubernetes stack)." Enums(1,2,3)
-// @param method query string true "Stack deployment method. Possible values: file, string, repository or url." Enums(string, file, repository, url)
-// @param endpointId query int true "Identifier of the environment(endpoint) that will be used to deploy the stack"
-// @param body body object true "for body documentation see the relevant /stacks/create/{type}/{method} endpoint"
-// @success 200 {object} portainer.Stack
-// @failure 400 "Invalid request"
-// @failure 500 "Server error"
-// @deprecated
-// @router /stacks [post]
-func deprecatedStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
- method, err := request.RetrieveQueryParameter(r, "method", false)
- if err != nil {
- return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
- }
-
- stackType, err := getStackTypeFromQueryParameter(r)
- if err != nil {
- return "", httperror.BadRequest("Invalid query parameter: type", err)
- }
-
- return fmt.Sprintf("/stacks/create/%s/%s", stackType, method), nil
-}
diff --git a/api/http/handler/system/handler.go b/api/http/handler/system/handler.go
index d2e3f5485..4cab43332 100644
--- a/api/http/handler/system/handler.go
+++ b/api/http/handler/system/handler.go
@@ -59,10 +59,6 @@ func NewHandler(bouncer security.BouncerService,
// Deprecated /status endpoint, will be removed in the future.
h.Handle("/status",
bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspectDeprecated))).Methods(http.MethodGet)
- h.Handle("/status/version",
- bouncer.AuthenticatedAccess(http.HandlerFunc(h.versionDeprecated))).Methods(http.MethodGet)
- h.Handle("/status/nodes",
- bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.statusNodesCountDeprecated))).Methods(http.MethodGet)
return h
}
diff --git a/api/http/handler/system/nodes_count.go b/api/http/handler/system/nodes_count.go
index 0b9971619..94ab320fa 100644
--- a/api/http/handler/system/nodes_count.go
+++ b/api/http/handler/system/nodes_count.go
@@ -8,8 +8,6 @@ import (
"github.com/portainer/portainer/api/internal/snapshot"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
-
- "github.com/rs/zerolog/log"
)
type nodesCountResponse struct {
@@ -44,21 +42,3 @@ func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request)
return response.JSON(w, &nodesCountResponse{Nodes: nodes})
}
-
-// @id statusNodesCount
-// @summary Retrieve the count of nodes
-// @deprecated
-// @description Deprecated: use the `/system/nodes` endpoint instead.
-// @description **Access policy**: authenticated
-// @security ApiKeyAuth
-// @security jwt
-// @tags status
-// @produce json
-// @success 200 {object} nodesCountResponse "Success"
-// @failure 500 "Server error"
-// @router /status/nodes [get]
-func (handler *Handler) statusNodesCountDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- log.Warn().Msg("The /status/nodes endpoint is deprecated, please use the /system/nodes endpoint instead")
-
- return handler.systemNodesCount(w, r)
-}
diff --git a/api/http/handler/system/version.go b/api/http/handler/system/version.go
index 50ad2f6af..9d80b88a9 100644
--- a/api/http/handler/system/version.go
+++ b/api/http/handler/system/version.go
@@ -106,21 +106,3 @@ func HasNewerVersion(currentVersion, latestVersion string) bool {
return currentVersionSemver.LessThan(*latestVersionSemver)
}
-
-// @id Version
-// @summary Check for portainer updates
-// @deprecated
-// @description Deprecated: use the `/system/version` endpoint instead.
-// @description Check if portainer has an update available
-// @description **Access policy**: authenticated
-// @security ApiKeyAuth
-// @security jwt
-// @tags status
-// @produce json
-// @success 200 {object} versionResponse "Success"
-// @router /status/version [get]
-func (handler *Handler) versionDeprecated(w http.ResponseWriter, r *http.Request) {
- log.Warn().Msg("The /status/version endpoint is deprecated, please use the /system/version endpoint instead")
-
- handler.version(w, r)
-}
diff --git a/api/http/handler/templates/handler.go b/api/http/handler/templates/handler.go
index e604e8abe..1e7b7f05d 100644
--- a/api/http/handler/templates/handler.go
+++ b/api/http/handler/templates/handler.go
@@ -29,7 +29,5 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
h.Handle("/templates/{id}/file",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFile))).Methods(http.MethodPost)
- h.Handle("/templates/file",
- bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateFileOld))).Methods(http.MethodPost)
return h
}
diff --git a/api/http/handler/templates/template_file_old.go b/api/http/handler/templates/template_file_old.go
deleted file mode 100644
index 91ac038c5..000000000
--- a/api/http/handler/templates/template_file_old.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package templates
-
-import (
- "errors"
- "net/http"
-
- httperror "github.com/portainer/portainer/pkg/libhttp/error"
- "github.com/portainer/portainer/pkg/libhttp/request"
- "github.com/portainer/portainer/pkg/libhttp/response"
- "github.com/rs/zerolog/log"
-)
-
-type filePayload struct {
- // URL of a git repository where the file is stored
- RepositoryURL string `example:"https://github.com/portainer/portainer-compose" validate:"required"`
- // Path to the file inside the git repository
- ComposeFilePathInRepository string `example:"./subfolder/docker-compose.yml" validate:"required"`
-}
-
-func (payload *filePayload) Validate(r *http.Request) error {
- if len(payload.RepositoryURL) == 0 {
- return errors.New("Invalid repository url")
- }
-
- if len(payload.ComposeFilePathInRepository) == 0 {
- return errors.New("Invalid file path")
- }
-
- return nil
-}
-
-func (handler *Handler) ifRequestedTemplateExists(payload *filePayload) *httperror.HandlerError {
- response, httpErr := handler.fetchTemplates()
- if httpErr != nil {
- return httpErr
- }
-
- for _, t := range response.Templates {
- if t.Repository.URL == payload.RepositoryURL && t.Repository.StackFile == payload.ComposeFilePathInRepository {
- return nil
- }
- }
- return httperror.InternalServerError("Invalid template", errors.New("requested template does not exist"))
-}
-
-// @id TemplateFileOld
-// @summary Get a template's file
-// @deprecated
-// @description Get a template's file
-// @description **Access policy**: authenticated
-// @tags templates
-// @security ApiKeyAuth
-// @security jwt
-// @accept json
-// @produce json
-// @param body body filePayload true "File details"
-// @success 200 {object} fileResponse "Success"
-// @failure 400 "Invalid request"
-// @failure 500 "Server error"
-// @router /templates/file [post]
-func (handler *Handler) templateFileOld(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- log.Warn().Msg("This api is deprecated. Please use /templates/{id}/file instead")
-
- var payload filePayload
- err := request.DecodeAndValidateJSONPayload(r, &payload)
- if err != nil {
- return httperror.BadRequest("Invalid request payload", err)
- }
-
- if err := handler.ifRequestedTemplateExists(&payload); err != nil {
- return err
- }
-
- projectPath, err := handler.FileService.GetTemporaryPath()
- if err != nil {
- return httperror.InternalServerError("Unable to create temporary folder", err)
- }
-
- defer handler.cleanUp(projectPath)
-
- err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "", false)
- if err != nil {
- return httperror.InternalServerError("Unable to clone git repository", err)
- }
-
- fileContent, err := handler.FileService.GetFileContent(projectPath, payload.ComposeFilePathInRepository)
- if err != nil {
- return httperror.InternalServerError("Failed loading file content", err)
- }
-
- return response.JSON(w, fileResponse{FileContent: string(fileContent)})
-
-}
diff --git a/api/http/server.go b/api/http/server.go
index ec86e6220..c5bb0d40f 100644
--- a/api/http/server.go
+++ b/api/http/server.go
@@ -24,7 +24,6 @@ import (
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks"
- "github.com/portainer/portainer/api/http/handler/edgetemplates"
"github.com/portainer/portainer/api/http/handler/endpointedge"
"github.com/portainer/portainer/api/http/handler/endpointgroups"
"github.com/portainer/portainer/api/http/handler/endpointproxy"
@@ -169,9 +168,6 @@ func (server *Server) Start() error {
edgeStacksHandler.GitService = server.GitService
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
- var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer)
- edgeTemplatesHandler.DataStore = server.DataStore
-
var endpointHandler = endpoints.NewHandler(requestBouncer)
endpointHandler.DataStore = server.DataStore
endpointHandler.FileService = server.FileService
@@ -306,7 +302,6 @@ func (server *Server) Start() error {
EdgeGroupsHandler: edgeGroupsHandler,
EdgeJobsHandler: edgeJobsHandler,
EdgeStacksHandler: edgeStacksHandler,
- EdgeTemplatesHandler: edgeTemplatesHandler,
EndpointGroupHandler: endpointGroupHandler,
EndpointHandler: endpointHandler,
EndpointHelmHandler: endpointHelmHandler,
diff --git a/app/constants.ts b/app/constants.ts
index 8a8e687d2..e61172d27 100644
--- a/app/constants.ts
+++ b/app/constants.ts
@@ -5,7 +5,6 @@ export const API_ENDPOINT_CUSTOM_TEMPLATES = 'api/custom_templates';
export const API_ENDPOINT_EDGE_GROUPS = 'api/edge_groups';
export const API_ENDPOINT_EDGE_JOBS = 'api/edge_jobs';
export const API_ENDPOINT_EDGE_STACKS = 'api/edge_stacks';
-export const API_ENDPOINT_EDGE_TEMPLATES = 'api/edge_templates';
export const API_ENDPOINT_ENDPOINTS = 'api/endpoints';
export const API_ENDPOINT_ENDPOINT_GROUPS = 'api/endpoint_groups';
export const API_ENDPOINT_KUBERNETES = 'api/kubernetes';
diff --git a/app/ng-constants.ts b/app/ng-constants.ts
index 527d42132..87fa45b97 100644
--- a/app/ng-constants.ts
+++ b/app/ng-constants.ts
@@ -7,7 +7,6 @@ import {
API_ENDPOINT_EDGE_GROUPS,
API_ENDPOINT_EDGE_JOBS,
API_ENDPOINT_EDGE_STACKS,
- API_ENDPOINT_EDGE_TEMPLATES,
API_ENDPOINT_ENDPOINTS,
API_ENDPOINT_ENDPOINT_GROUPS,
API_ENDPOINT_KUBERNETES,
@@ -42,7 +41,6 @@ export const constantsModule = angular
.constant('API_ENDPOINT_EDGE_GROUPS', API_ENDPOINT_EDGE_GROUPS)
.constant('API_ENDPOINT_EDGE_JOBS', API_ENDPOINT_EDGE_JOBS)
.constant('API_ENDPOINT_EDGE_STACKS', API_ENDPOINT_EDGE_STACKS)
- .constant('API_ENDPOINT_EDGE_TEMPLATES', API_ENDPOINT_EDGE_TEMPLATES)
.constant('API_ENDPOINT_ENDPOINTS', API_ENDPOINT_ENDPOINTS)
.constant('API_ENDPOINT_ENDPOINT_GROUPS', API_ENDPOINT_ENDPOINT_GROUPS)
.constant('API_ENDPOINT_KUBERNETES', API_ENDPOINT_KUBERNETES)
From 4bb80d3e3afde96e257025e92cea33754c4b05c5 Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Mon, 10 Feb 2025 21:05:15 +1300
Subject: [PATCH 016/212] fix(setting): failed to persist edge computer setting
[BE-11403] (#395)
---
api/cmd/portainer/main.go | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go
index edc9cb897..88e21f17b 100644
--- a/api/cmd/portainer/main.go
+++ b/api/cmd/portainer/main.go
@@ -238,10 +238,10 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
return err
}
- settings.SnapshotInterval = *cmp.Or(flags.SnapshotInterval, &settings.SnapshotInterval)
- settings.LogoURL = *cmp.Or(flags.Logo, &settings.LogoURL)
- settings.EnableEdgeComputeFeatures = *cmp.Or(flags.EnableEdgeComputeFeatures, &settings.EnableEdgeComputeFeatures)
- settings.TemplatesURL = *cmp.Or(flags.Templates, &settings.TemplatesURL)
+ settings.SnapshotInterval = cmp.Or(*flags.SnapshotInterval, settings.SnapshotInterval)
+ settings.LogoURL = cmp.Or(*flags.Logo, settings.LogoURL)
+ settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
+ settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
if *flags.Labels != nil {
settings.BlackListedLabels = *flags.Labels
From b25bf1e341e2f500d873c5ca23824b6789cd4d2d Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Mon, 10 Feb 2025 21:08:27 +1300
Subject: [PATCH 017/212] fix(podman): missing filter in homepage [BE-11502]
(#404)
---
.../EnvironmentList/EnvironmentList.tsx | 1 +
.../EnvironmentListFilters.tsx | 15 ++++++++--
.../environments/environment.service/index.ts | 2 ++
.../queries/useEnvironmentList.ts | 29 ++++++++++++++++++-
4 files changed, 44 insertions(+), 3 deletions(-)
diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx
index 4805ce6e6..9884048b3 100644
--- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx
+++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx
@@ -109,6 +109,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
agentVersions,
updateInformation: isBE,
edgeAsync: getEdgeAsyncValue(connectionTypes),
+ platformTypes,
};
const queryWithSort = {
diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx
index 3c4d993e4..06d1c5108 100644
--- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx
+++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx
@@ -209,6 +209,7 @@ function getPlatformTypeOptions(connectionTypes: ConnectionType[]) {
{ value: PlatformType.Docker, label: 'Docker' },
{ value: PlatformType.Azure, label: 'Azure' },
{ value: PlatformType.Kubernetes, label: 'Kubernetes' },
+ { value: PlatformType.Podman, label: 'Podman' },
];
if (connectionTypes.length === 0) {
@@ -216,15 +217,25 @@ function getPlatformTypeOptions(connectionTypes: ConnectionType[]) {
}
const connectionTypePlatformType = {
- [ConnectionType.API]: [PlatformType.Docker, PlatformType.Azure],
- [ConnectionType.Agent]: [PlatformType.Docker, PlatformType.Kubernetes],
+ [ConnectionType.API]: [
+ PlatformType.Docker,
+ PlatformType.Azure,
+ PlatformType.Podman,
+ ],
+ [ConnectionType.Agent]: [
+ PlatformType.Docker,
+ PlatformType.Kubernetes,
+ PlatformType.Podman,
+ ],
[ConnectionType.EdgeAgentStandard]: [
PlatformType.Kubernetes,
PlatformType.Docker,
+ PlatformType.Podman,
],
[ConnectionType.EdgeAgentAsync]: [
PlatformType.Docker,
PlatformType.Kubernetes,
+ PlatformType.Podman,
],
};
diff --git a/app/react/portainer/environments/environment.service/index.ts b/app/react/portainer/environments/environment.service/index.ts
index 09844e6ce..8971a4af0 100644
--- a/app/react/portainer/environments/environment.service/index.ts
+++ b/app/react/portainer/environments/environment.service/index.ts
@@ -6,6 +6,7 @@ import {
EnvironmentSecuritySettings,
EnvironmentStatus,
EnvironmentGroupId,
+ PlatformType,
} from '@/react/portainer/environments/types';
import { type TagId } from '@/portainer/tags/types';
import { UserId } from '@/portainer/users/types';
@@ -45,6 +46,7 @@ export interface BaseEnvironmentsQueryParams {
agentVersions?: string[];
updateInformation?: boolean;
edgeCheckInPassedSeconds?: number;
+ platformTypes?: PlatformType[];
}
export type EnvironmentsQueryParams = BaseEnvironmentsQueryParams &
diff --git a/app/react/portainer/environments/queries/useEnvironmentList.ts b/app/react/portainer/environments/queries/useEnvironmentList.ts
index ef545b06f..850fc7cfd 100644
--- a/app/react/portainer/environments/queries/useEnvironmentList.ts
+++ b/app/react/portainer/environments/queries/useEnvironmentList.ts
@@ -1,8 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { withError } from '@/react-tools/react-query';
+import {
+ PlatformType,
+ EnvironmentStatus,
+} from '@/react/portainer/environments/types';
-import { EnvironmentStatus } from '../types';
import {
EnvironmentsQueryParams,
getEnvironments,
@@ -98,6 +101,30 @@ export function useEnvironmentList(
}
);
+ if (data?.value && query && query.platformTypes) {
+ const platforms = Array.from(query.platformTypes);
+
+ if (
+ platforms.includes(PlatformType.Podman) !==
+ platforms.includes(PlatformType.Docker)
+ ) {
+ const isPodmanSelected = platforms.includes(PlatformType.Podman);
+ const containerEngineToExclude = isPodmanSelected ? 'docker' : 'podman';
+
+ const filteredList = data?.value.filter(
+ (env) => env.ContainerEngine !== containerEngineToExclude
+ );
+
+ return {
+ isLoading,
+ environments: filteredList,
+ totalCount: data ? data.totalCount : 0,
+ totalAvailable: data ? data.totalAvailable : 0,
+ updateAvailable: data ? data.updateAvailable : false,
+ };
+ }
+ }
+
return {
isLoading,
environments: data ? data.value : [],
From 2d3e5c3499812d359476b8440bdc5ffb5255a4ae Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Tue, 11 Feb 2025 15:36:29 +1300
Subject: [PATCH 018/212] workaround: leave the globally set helm repo to empty
and add disclaimer - develop (#409)
---
api/database/boltdb/json_test.go | 2 +-
.../test_data/output_24_to_latest.json | 2 +-
api/http/handler/helm/helm_install_test.go | 2 +-
.../handler/helm/helm_repo_search_test.go | 2 +-
api/http/handler/helm/helm_show_test.go | 2 +-
api/http/handler/settings/settings_update.go | 2 +-
api/portainer.go | 10 ++---
.../helm-templates-list.html | 38 +++++++++++++++++--
.../KubeSettingsPanel/HelmSection.tsx | 24 +++++++++++-
pkg/libhelm/binary/install_test.go | 12 +++---
pkg/libhelm/binary/search_repo_test.go | 2 +-
pkg/libhelm/validate_repo_test.go | 2 +-
12 files changed, 76 insertions(+), 24 deletions(-)
diff --git a/api/database/boltdb/json_test.go b/api/database/boltdb/json_test.go
index 577aa2cfd..ba0863efd 100644
--- a/api/database/boltdb/json_test.go
+++ b/api/database/boltdb/json_test.go
@@ -10,7 +10,7 @@ import (
)
const (
- jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
+ jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://kubernetes.github.io/ingress-nginx","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
passphrase = "my secret key"
)
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index 43d872914..d9b9bf036 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -605,7 +605,7 @@
"GlobalDeploymentOptions": {
"hideStacksFunctionality": false
},
- "HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
+ "HelmRepositoryURL": "",
"InternalAuthSettings": {
"RequiredPasswordLength": 12
},
diff --git a/api/http/handler/helm/helm_install_test.go b/api/http/handler/helm/helm_install_test.go
index 869b80554..0b87d3a23 100644
--- a/api/http/handler/helm/helm_install_test.go
+++ b/api/http/handler/helm/helm_install_test.go
@@ -45,7 +45,7 @@ func Test_helmInstall(t *testing.T) {
is.NotNil(h, "Handler should not fail")
// Install a single chart. We expect to get these values back
- options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://charts.bitnami.com/bitnami"}
+ options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://kubernetes.github.io/ingress-nginx"}
optdata, err := json.Marshal(options)
is.NoError(err)
diff --git a/api/http/handler/helm/helm_repo_search_test.go b/api/http/handler/helm/helm_repo_search_test.go
index 5fde5e642..a556908b9 100644
--- a/api/http/handler/helm/helm_repo_search_test.go
+++ b/api/http/handler/helm/helm_repo_search_test.go
@@ -20,7 +20,7 @@ func Test_helmRepoSearch(t *testing.T) {
assert.NotNil(t, h, "Handler should not fail")
- repos := []string{"https://charts.bitnami.com/bitnami", "https://portainer.github.io/k8s"}
+ repos := []string{"https://kubernetes.github.io/ingress-nginx", "https://portainer.github.io/k8s"}
for _, repo := range repos {
t.Run(repo, func(t *testing.T) {
diff --git a/api/http/handler/helm/helm_show_test.go b/api/http/handler/helm/helm_show_test.go
index dd3957c99..fed59388d 100644
--- a/api/http/handler/helm/helm_show_test.go
+++ b/api/http/handler/helm/helm_show_test.go
@@ -31,7 +31,7 @@ func Test_helmShow(t *testing.T) {
t.Run(cmd, func(t *testing.T) {
is.NotNil(h, "Handler should not fail")
- repoUrlEncoded := url.QueryEscape("https://charts.bitnami.com/bitnami")
+ repoUrlEncoded := url.QueryEscape("https://kubernetes.github.io/ingress-nginx")
chart := "nginx"
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil)
rr := httptest.NewRecorder()
diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go
index 0b36dbc62..895da489c 100644
--- a/api/http/handler/settings/settings_update.go
+++ b/api/http/handler/settings/settings_update.go
@@ -46,7 +46,7 @@ type settingsUpdatePayload struct {
// Whether telemetry is enabled
EnableTelemetry *bool `example:"false"`
// Helm repository URL
- HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
+ HelmRepositoryURL *string `example:"https://kubernetes.github.io/ingress-nginx"`
// Kubectl Shell Image
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
diff --git a/api/portainer.go b/api/portainer.go
index 64ac47012..9a6d719e8 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -588,7 +588,7 @@ type (
// User identifier
UserID UserID `json:"UserId" example:"1"`
// Helm repository URL
- URL string `json:"URL" example:"https://charts.bitnami.com/bitnami"`
+ URL string `json:"URL" example:"https://kubernetes.github.io/ingress-nginx"`
}
// QuayRegistryData represents data required for Quay registry to work
@@ -984,8 +984,8 @@ type (
KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"`
// Whether telemetry is enabled
EnableTelemetry bool `json:"EnableTelemetry" example:"false"`
- // Helm repository URL, defaults to "https://charts.bitnami.com/bitnami"
- HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
+ // Helm repository URL, defaults to ""
+ HelmRepositoryURL string `json:"HelmRepositoryURL"`
// KubectlImage, defaults to portainer/kubectl-shell
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
@@ -1672,8 +1672,8 @@ const (
DefaultEdgeAgentCheckinIntervalInSeconds = 5
// DefaultTemplatesURL represents the URL to the official templates supported by Portainer
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/v3/templates.json"
- // DefaultHelmrepositoryURL represents the URL to the official templates supported by Bitnami
- DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami"
+ // DefaultHelmrepositoryURL set to empty string until oci support is added
+ DefaultHelmRepositoryURL = ""
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
DefaultUserSessionTimeout = "8h"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
index 8f3f2fb4a..504c53af3 100644
--- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
+++ b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
@@ -31,10 +31,40 @@
>Select the Helm chart to use. Bring further Helm charts into your selection list via
User settings - Helm repositories .
-
+
+
Select the Helm chart to use. Bring further Helm charts into your selection list via
+
User settings - Helm repositories .
+
+
+
+
Disclaimer
+
+ At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.
+ If you would like to provide feedback on OCI support or get access to early releases to test this functionality,
+
please get in touch .
+
+
+
+
diff --git a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx
index 22790e781..e5d519174 100644
--- a/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx
+++ b/app/react/portainer/settings/SettingsView/KubeSettingsPanel/HelmSection.tsx
@@ -4,6 +4,7 @@ import { TextTip } from '@@/Tip/TextTip';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
import { Input } from '@@/form-components/Input';
+import { InsightsBox } from '@@/InsightsBox';
export function HelmSection() {
const [{ name }, { error }] = useField('helmRepositoryUrl');
@@ -24,13 +25,34 @@ export function HelmSection() {
+
+ At present Portainer does not support OCI format Helm charts.
+ Support for OCI charts will be available in a future release. If you
+ would like to provide feedback on OCI support or get access to early
+ releases to test this functionality,{' '}
+
+ please get in touch
+
+ .
+ >
+ }
+ className="block w-fit mt-2 mb-1"
+ />
+
diff --git a/pkg/libhelm/binary/install_test.go b/pkg/libhelm/binary/install_test.go
index e3252e1f2..3d6c74707 100644
--- a/pkg/libhelm/binary/install_test.go
+++ b/pkg/libhelm/binary/install_test.go
@@ -53,11 +53,11 @@ func Test_Install(t *testing.T) {
hbpm := NewHelmBinaryPackageManager(path)
t.Run("successfully installs nginx chart with name test-nginx", func(t *testing.T) {
- // helm install test-nginx --repo https://charts.bitnami.com/bitnami nginx
+ // helm install test-nginx --repo https://kubernetes.github.io/ingress-nginx nginx
installOpts := options.InstallOptions{
Name: "test-nginx",
Chart: "nginx",
- Repo: "https://charts.bitnami.com/bitnami",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
}
release, err := hbpm.Install(installOpts)
@@ -67,10 +67,10 @@ func Test_Install(t *testing.T) {
})
t.Run("successfully installs nginx chart with generated name", func(t *testing.T) {
- // helm install --generate-name --repo https://charts.bitnami.com/bitnami nginx
+ // helm install --generate-name --repo https://kubernetes.github.io/ingress-nginx nginx
installOpts := options.InstallOptions{
Chart: "nginx",
- Repo: "https://charts.bitnami.com/bitnami",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
}
release, err := hbpm.Install(installOpts)
defer hbpm.run("uninstall", []string{release.Name}, nil)
@@ -79,7 +79,7 @@ func Test_Install(t *testing.T) {
})
t.Run("successfully installs nginx with values", func(t *testing.T) {
- // helm install test-nginx-2 --repo https://charts.bitnami.com/bitnami nginx --values /tmp/helm-values3161785816
+ // helm install test-nginx-2 --repo https://kubernetes.github.io/ingress-nginx nginx --values /tmp/helm-values3161785816
values, err := createValuesFile("service:\n port: 8081")
is.NoError(err, "should create a values file")
@@ -88,7 +88,7 @@ func Test_Install(t *testing.T) {
installOpts := options.InstallOptions{
Name: "test-nginx-2",
Chart: "nginx",
- Repo: "https://charts.bitnami.com/bitnami",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values,
}
release, err := hbpm.Install(installOpts)
diff --git a/pkg/libhelm/binary/search_repo_test.go b/pkg/libhelm/binary/search_repo_test.go
index 4d5dd4d82..48786a0d7 100644
--- a/pkg/libhelm/binary/search_repo_test.go
+++ b/pkg/libhelm/binary/search_repo_test.go
@@ -22,7 +22,7 @@ func Test_SearchRepo(t *testing.T) {
tests := []testCase{
{"not a helm repo", "https://portainer.io", true},
- {"bitnami helm repo", "https://charts.bitnami.com/bitnami", false},
+ {"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", false},
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
{"gitlap helm repo with trailing slash", "https://charts.gitlab.io/", false},
{"elastic helm repo with trailing slash", "https://helm.elastic.co/", false},
diff --git a/pkg/libhelm/validate_repo_test.go b/pkg/libhelm/validate_repo_test.go
index 7f81311ba..dce1257d0 100644
--- a/pkg/libhelm/validate_repo_test.go
+++ b/pkg/libhelm/validate_repo_test.go
@@ -26,7 +26,7 @@ func Test_ValidateHelmRepositoryURL(t *testing.T) {
{"not helm repo", "http://google.com", true},
{"not valid repo with trailing slash", "http://google.com/", true},
{"not valid repo with trailing slashes", "http://google.com////", true},
- {"bitnami helm repo", "https://charts.bitnami.com/bitnami/", false},
+ {"ingress helm repo", "https://kubernetes.github.io/ingress-nginx/", false},
{"gitlap helm repo", "https://charts.gitlab.io/", false},
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
{"elastic helm repo", "https://helm.elastic.co/", false},
From e45b852c0948c0d24656224f1667c0d3108cdaa4 Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Wed, 12 Feb 2025 09:23:52 +1300
Subject: [PATCH 019/212] fix(platform): remove error log when local env is not
found [BE-11353] (#364)
---
api/http/handler/system/system_info.go | 8 +++++++-
api/http/handler/system/system_upgrade.go | 7 +++++--
api/platform/service.go | 10 +++++++---
3 files changed, 19 insertions(+), 6 deletions(-)
diff --git a/api/http/handler/system/system_info.go b/api/http/handler/system/system_info.go
index ad5f944ef..64a915313 100644
--- a/api/http/handler/system/system_info.go
+++ b/api/http/handler/system/system_info.go
@@ -3,6 +3,7 @@ package system
import (
"net/http"
+ "github.com/pkg/errors"
"github.com/portainer/portainer/api/internal/endpointutils"
plf "github.com/portainer/portainer/api/platform"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -46,7 +47,12 @@ func (handler *Handler) systemInfo(w http.ResponseWriter, r *http.Request) *http
platform, err := handler.platformService.GetPlatform()
if err != nil {
- return httperror.InternalServerError("Failed to get platform", err)
+ if !errors.Is(err, plf.ErrNoLocalEnvironment) {
+ return httperror.InternalServerError("Failed to get platform", err)
+ }
+ // If no local environment is detected, we assume the platform is Docker
+ // UI will stop showing the upgrade banner
+ platform = plf.PlatformDocker
}
return response.JSON(w, &systemInfoResponse{
diff --git a/api/http/handler/system/system_upgrade.go b/api/http/handler/system/system_upgrade.go
index f881b6233..8eb41413f 100644
--- a/api/http/handler/system/system_upgrade.go
+++ b/api/http/handler/system/system_upgrade.go
@@ -4,6 +4,7 @@ import (
"net/http"
"regexp"
+ ceplf "github.com/portainer/portainer/api/platform"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -45,6 +46,9 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h
environment, err := handler.platformService.GetLocalEnvironment()
if err != nil {
+ if errors.Is(err, ceplf.ErrNoLocalEnvironment) {
+ return httperror.NotFound("The system upgrade feature is disabled because no local environment was detected.", err)
+ }
return httperror.InternalServerError("Failed to get local environment", err)
}
@@ -53,8 +57,7 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h
return httperror.InternalServerError("Failed to get platform", err)
}
- err = handler.upgradeService.Upgrade(platform, environment, payload.License)
- if err != nil {
+ if err := handler.upgradeService.Upgrade(platform, environment, payload.License); err != nil {
return httperror.InternalServerError("Failed to upgrade Portainer", err)
}
diff --git a/api/platform/service.go b/api/platform/service.go
index 628e1cf9b..e74855fb7 100644
--- a/api/platform/service.go
+++ b/api/platform/service.go
@@ -14,6 +14,10 @@ import (
"github.com/rs/zerolog/log"
)
+var (
+ ErrNoLocalEnvironment = errors.New("No local environment was detected")
+)
+
type Service interface {
GetLocalEnvironment() (*portainer.Endpoint, error)
GetPlatform() (ContainerPlatform, error)
@@ -35,7 +39,7 @@ func (service *service) loadEnvAndPlatform() error {
return nil
}
- environment, platform, err := guessLocalEnvironment(service.dataStore)
+ environment, platform, err := detectLocalEnvironment(service.dataStore)
if err != nil {
return err
}
@@ -73,7 +77,7 @@ var platformToEndpointType = map[ContainerPlatform][]portainer.EndpointType{
PlatformKubernetes: {portainer.KubernetesLocalEnvironment},
}
-func guessLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoint, ContainerPlatform, error) {
+func detectLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoint, ContainerPlatform, error) {
platform := DetermineContainerPlatform()
if !slices.Contains([]ContainerPlatform{PlatformDocker, PlatformKubernetes}, platform) {
@@ -113,7 +117,7 @@ func guessLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoin
}
}
- return nil, "", errors.New("failed to find local environment")
+ return nil, "", ErrNoLocalEnvironment
}
func checkDockerEnvTypeForUpgrade(environment *portainer.Endpoint) ContainerPlatform {
From 96b1869a0cc74310ef49de148aa6c3c1a9b7a759 Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Tue, 11 Feb 2025 20:47:45 -0300
Subject: [PATCH 020/212] fix(swarm): fix the Host field when listing images
BE-10827 (#352)
Co-authored-by: andres-portainer
Co-authored-by: LP B
---
api/docker/client/client.go | 18 +++++++-------
api/http/handler/docker/images/images_list.go | 24 +++++++++++--------
2 files changed, 23 insertions(+), 19 deletions(-)
diff --git a/api/docker/client/client.go b/api/docker/client/client.go
index 065a40382..1161c4995 100644
--- a/api/docker/client/client.go
+++ b/api/docker/client/client.go
@@ -3,8 +3,8 @@ package client
import (
"bytes"
"errors"
+ "fmt"
"io"
- "maps"
"net/http"
"strings"
"time"
@@ -141,7 +141,6 @@ func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatu
type NodeNameTransport struct {
*http.Transport
- nodeNames map[string]string
}
func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -176,18 +175,19 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
return resp, nil
}
- t.nodeNames = make(map[string]string)
- for _, r := range rs {
- t.nodeNames[r.ID] = r.Portainer.Agent.NodeName
+ nodeNames, ok := req.Context().Value("nodeNames").(map[string]string)
+ if ok {
+ for idx, r := range rs {
+ // as there is no way to differentiate the same image available in multiple nodes only by their ID
+ // we append the index of the image in the payload response to match the node name later
+ // from the image.Summary[] list returned by docker's client.ImageList()
+ nodeNames[fmt.Sprintf("%s-%d", r.ID, idx)] = r.Portainer.Agent.NodeName
+ }
}
return resp, err
}
-func (t *NodeNameTransport) NodeNames() map[string]string {
- return maps.Clone(t.nodeNames)
-}
-
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
transport := &NodeNameTransport{
Transport: &http.Transport{},
diff --git a/api/http/handler/docker/images/images_list.go b/api/http/handler/docker/images/images_list.go
index ac7e980a0..eca99d993 100644
--- a/api/http/handler/docker/images/images_list.go
+++ b/api/http/handler/docker/images/images_list.go
@@ -1,10 +1,11 @@
package images
import (
+ "context"
+ "fmt"
"net/http"
"strings"
- "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/handler/docker/utils"
"github.com/portainer/portainer/api/set"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -46,17 +47,16 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
return httpErr
}
- images, err := cli.ImageList(r.Context(), image.ListOptions{})
+ nodeNames := make(map[string]string)
+
+ // Pass the node names map to the context so the custom NodeNameTransport can use it
+ ctx := context.WithValue(r.Context(), "nodeNames", nodeNames)
+
+ images, err := cli.ImageList(ctx, image.ListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker images", err)
}
- // Extract the node name from the custom transport
- nodeNames := make(map[string]string)
- if t, ok := cli.HTTPClient().Transport.(*client.NodeNameTransport); ok {
- nodeNames = t.NodeNames()
- }
-
withUsage, err := request.RetrieveBooleanQueryParameter(r, "withUsage", true)
if err != nil {
return httperror.BadRequest("Invalid query parameter: withUsage", err)
@@ -85,8 +85,12 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
}
imagesList[i] = ImageResponse{
- Created: image.Created,
- NodeName: nodeNames[image.ID],
+ Created: image.Created,
+ // Only works if the order of `images` is not changed between unmarshaling the agent's response
+ // in NodeNameTransport.RoundTrip() (api/docker/client/client.go)
+ // and docker's cli.ImageList()
+ // As both functions unmarshal the same response body, the resulting array will be ordered the same way.
+ NodeName: nodeNames[fmt.Sprintf("%s-%d", image.ID, i)],
ID: image.ID,
Size: image.Size,
Tags: image.RepoTags,
From df8673ba405ce4147574f31e0fcb45c68bf94cb7 Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Fri, 14 Feb 2025 08:39:02 +1300
Subject: [PATCH 021/212] version: bump version to 2.27.0-rc3 - develop (#426)
---
api/datastore/test_data/output_24_to_latest.json | 4 ++--
api/portainer.go | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index d9b9bf036..d1c264094 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -610,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
- "KubectlShellImage": "portainer/kubectl-shell:2.27.0-rc2",
+ "KubectlShellImage": "portainer/kubectl-shell:2.27.0-rc3",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
- "VERSION": "{\"SchemaVersion\":\"2.27.0-rc2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
+ "VERSION": "{\"SchemaVersion\":\"2.27.0-rc3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
\ No newline at end of file
diff --git a/api/portainer.go b/api/portainer.go
index 9a6d719e8..dd48936ed 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -1636,7 +1636,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
- APIVersion = "2.27.0-rc2"
+ APIVersion = "2.27.0-rc3"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
// Edition is what this edition of Portainer is called
From 41c1d8861571fb686e1672eac7aba8d9a91562b7 Mon Sep 17 00:00:00 2001
From: Viktor Pettersson
Date: Wed, 19 Feb 2025 02:46:39 +0100
Subject: [PATCH 022/212] fix(edge): configure persisted mTLS certificates on
start-up [BE-11622] (#437)
Co-authored-by: andres-portainer
Co-authored-by: oscarzhou
Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
---
api/filesystem/filesystem.go | 45 +++++++++++++++++++++++++-----------
api/portainer.go | 3 ++-
2 files changed, 34 insertions(+), 14 deletions(-)
diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go
index df49a2706..43e38b4e7 100644
--- a/api/filesystem/filesystem.go
+++ b/api/filesystem/filesystem.go
@@ -841,11 +841,11 @@ func (service *Service) GetDefaultSSLCertsPath() (string, string) {
}
func defaultMTLSCertPathUnderFileStore() (string, string, string) {
- certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
caCertPath := JoinPaths(SSLCertPath, MTLSCACertFilename)
+ certPath := JoinPaths(SSLCertPath, MTLSCertFilename)
keyPath := JoinPaths(SSLCertPath, MTLSKeyFilename)
- return certPath, caCertPath, keyPath
+ return caCertPath, certPath, keyPath
}
// GetDefaultChiselPrivateKeyPath returns the chisle private key path
@@ -1014,26 +1014,45 @@ func CreateFile(path string, r io.Reader) error {
return err
}
-func (service *Service) StoreMTLSCertificates(cert, caCert, key []byte) (string, string, string, error) {
- certPath, caCertPath, keyPath := defaultMTLSCertPathUnderFileStore()
+func (service *Service) StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error) {
+ caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
- r := bytes.NewReader(cert)
- err := service.createFileInStore(certPath, r)
- if err != nil {
+ r := bytes.NewReader(caCert)
+ if err := service.createFileInStore(caCertPath, r); err != nil {
return "", "", "", err
}
- r = bytes.NewReader(caCert)
- err = service.createFileInStore(caCertPath, r)
- if err != nil {
+ r = bytes.NewReader(cert)
+ if err := service.createFileInStore(certPath, r); err != nil {
return "", "", "", err
}
r = bytes.NewReader(key)
- err = service.createFileInStore(keyPath, r)
- if err != nil {
+ if err := service.createFileInStore(keyPath, r); err != nil {
return "", "", "", err
}
- return service.wrapFileStore(certPath), service.wrapFileStore(caCertPath), service.wrapFileStore(keyPath), nil
+ return service.wrapFileStore(caCertPath), service.wrapFileStore(certPath), service.wrapFileStore(keyPath), nil
+}
+
+func (service *Service) GetMTLSCertificates() (string, string, string, error) {
+ caCertPath, certPath, keyPath := defaultMTLSCertPathUnderFileStore()
+
+ caCertPath = service.wrapFileStore(caCertPath)
+ certPath = service.wrapFileStore(certPath)
+ keyPath = service.wrapFileStore(keyPath)
+
+ paths := [...]string{caCertPath, certPath, keyPath}
+ for _, path := range paths {
+ exists, err := service.FileExists(path)
+ if err != nil {
+ return "", "", "", err
+ }
+
+ if !exists {
+ return "", "", "", fmt.Errorf("file %s does not exist", path)
+ }
+ }
+
+ return caCertPath, certPath, keyPath, nil
}
diff --git a/api/portainer.go b/api/portainer.go
index dd48936ed..2f9af995f 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -1491,7 +1491,8 @@ type (
StoreSSLCertPair(cert, key []byte) (string, string, error)
CopySSLCertPair(certPath, keyPath string) (string, string, error)
CopySSLCACert(caCertPath string) (string, error)
- StoreMTLSCertificates(cert, caCert, key []byte) (string, string, string, error)
+ StoreMTLSCertificates(caCert, cert, key []byte) (string, string, string, error)
+ GetMTLSCertificates() (string, string, string, error)
GetDefaultChiselPrivateKeyPath() string
StoreChiselPrivateKey(privateKey []byte) error
}
From cf95d91db39045adc83f566b7fe5c0a01632e314 Mon Sep 17 00:00:00 2001
From: LP B
Date: Wed, 19 Feb 2025 19:25:28 +0100
Subject: [PATCH 023/212] fix(swarm): keep swarm stack stop command attached
(#444)
---
api/exec/swarm_stack.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go
index 634194d67..b5e13dcc6 100644
--- a/api/exec/swarm_stack.go
+++ b/api/exec/swarm_stack.go
@@ -127,7 +127,7 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
return err
}
- args = append(args, "stack", "rm", stack.Name)
+ args = append(args, "stack", "rm", "--detach=false", stack.Name)
return runCommandAndCaptureStdErr(command, args, nil, "")
}
From 1b83542d41eb8f8acbc9c27aea355a31f64c9451 Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Thu, 20 Feb 2025 09:42:52 +1300
Subject: [PATCH 024/212] chore: bump version to 2.27.0 - develop (#445)
---
api/datastore/test_data/output_24_to_latest.json | 4 ++--
api/http/handler/handler.go | 2 +-
api/portainer.go | 2 +-
package.json | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index d1c264094..a9ebeffb5 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -610,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
- "KubectlShellImage": "portainer/kubectl-shell:2.27.0-rc3",
+ "KubectlShellImage": "portainer/kubectl-shell:2.27.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
- "VERSION": "{\"SchemaVersion\":\"2.27.0-rc3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
+ "VERSION": "{\"SchemaVersion\":\"2.27.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
\ No newline at end of file
diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go
index 4325d9f91..4877e76c7 100644
--- a/api/http/handler/handler.go
+++ b/api/http/handler/handler.go
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
-// @version 2.26.0
+// @version 2.27.0
// @description.markdown api-description.md
// @termsOfService
diff --git a/api/portainer.go b/api/portainer.go
index 2f9af995f..1195737d8 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -1637,7 +1637,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
- APIVersion = "2.27.0-rc3"
+ APIVersion = "2.27.0"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
// Edition is what this edition of Portainer is called
diff --git a/package.json b/package.json
index d9a9dcd6d..41d07244e 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
- "version": "2.26.0",
+ "version": "2.27.0",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"
From 5d568a3f3290f3e1b945fc0d9ff103238c14e45a Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Thu, 20 Feb 2025 12:09:26 +1300
Subject: [PATCH 025/212] fix(edge): edge stack pending when yaml file is under
same root folder of edge configs [BE-11620] (#447)
---
api/filesystem/serialize_per_dev_configs.go | 32 ++++---------------
.../serialize_per_dev_configs_test.go | 21 ++++++++++++
2 files changed, 28 insertions(+), 25 deletions(-)
diff --git a/api/filesystem/serialize_per_dev_configs.go b/api/filesystem/serialize_per_dev_configs.go
index c9d02ad0d..55881885a 100644
--- a/api/filesystem/serialize_per_dev_configs.go
+++ b/api/filesystem/serialize_per_dev_configs.go
@@ -44,11 +44,10 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
// FilterDirForPerDevConfigs filers the given dirEntries, returns entries for the given device
// For given configPath A/B/C, return entries:
-// 1. all entries outside of dir A
-// 2. dir entries A, A/B, A/B/C
-// 3. For filterType file:
+// 1. all entries outside of dir A/B/C
+// 2. For filterType file:
// file entries: A/B/C/ and A/B/C/.*
-// 4. For filterType dir:
+// 3. For filterType dir:
// dir entry: A/B/C/
// all entries: A/B/C//*
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) []DirEntry {
@@ -66,12 +65,7 @@ func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath str
func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
// Include all entries outside of dir A
- if !isInConfigRootDir(dirEntry, configPath) {
- return true
- }
-
- // Include dir entries A, A/B, A/B/C
- if isParentDir(dirEntry, configPath) {
+ if !isInConfigDir(dirEntry, configPath) {
return true
}
@@ -90,21 +84,9 @@ func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filter
return false
}
-func isInConfigRootDir(dirEntry DirEntry, configPath string) bool {
- // get the first element of the configPath
- rootDir := strings.Split(configPath, string(os.PathSeparator))[0]
-
- // return true if entry name starts with "A/"
- return strings.HasPrefix(dirEntry.Name, appendTailSeparator(rootDir))
-}
-
-func isParentDir(dirEntry DirEntry, configPath string) bool {
- if dirEntry.IsFile {
- return false
- }
-
- // return true for dir entries A, A/B, A/B/C
- return strings.HasPrefix(appendTailSeparator(configPath), appendTailSeparator(dirEntry.Name))
+func isInConfigDir(dirEntry DirEntry, configPath string) bool {
+ // return true if entry name starts with "A/B"
+ return strings.HasPrefix(dirEntry.Name, appendTailSeparator(configPath))
}
func shouldIncludeFile(dirEntry DirEntry, deviceName, configPath string) bool {
diff --git a/api/filesystem/serialize_per_dev_configs_test.go b/api/filesystem/serialize_per_dev_configs_test.go
index 6a2a5f33b..55a1a4643 100644
--- a/api/filesystem/serialize_per_dev_configs_test.go
+++ b/api/filesystem/serialize_per_dev_configs_test.go
@@ -90,3 +90,24 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
})
}
}
+
+func TestIsInConfigDir(t *testing.T) {
+ f := func(dirEntry DirEntry, configPath string, expect bool) {
+ t.Helper()
+
+ actual := isInConfigDir(dirEntry, configPath)
+ assert.Equal(t, expect, actual)
+ }
+
+ f(DirEntry{Name: "edge-configs"}, "edge-configs", false)
+ f(DirEntry{Name: "edge-configs_backup"}, "edge-configs", false)
+ f(DirEntry{Name: "edge-configs/standalone-edge-agent-standard"}, "edge-configs", true)
+ f(DirEntry{Name: "parent/edge-configs/"}, "edge-configs", false)
+ f(DirEntry{Name: "edgestacktest"}, "edgestacktest/edge-configs", false)
+ f(DirEntry{Name: "edgestacktest/edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
+ f(DirEntry{Name: "edgestacktest/file1.conf"}, "edgestacktest/edge-configs", false)
+ f(DirEntry{Name: "edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
+ f(DirEntry{Name: "edgestacktest/edge-configs"}, "edgestacktest/edge-configs", false)
+ f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true)
+ f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true)
+}
From 9c243cc8dd4427c9158c76414f35b93685a5be96 Mon Sep 17 00:00:00 2001
From: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Date: Thu, 20 Feb 2025 13:38:26 +1300
Subject: [PATCH 026/212] Update bug report template for 2.27.0 (#450)
---
.github/ISSUE_TEMPLATE/bug_report.yml | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index e1ab32632..e2eef4f66 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -95,6 +95,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 [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
+ - '2.27.0'
- '2.26.1'
- '2.26.0'
- '2.25.1'
@@ -119,10 +120,6 @@ body:
- '2.19.2'
- '2.19.1'
- '2.19.0'
- - '2.18.4'
- - '2.18.3'
- - '2.18.2'
- - '2.18.1'
validations:
required: true
From cc73b7831f13b48fdfbbcd80be11add8a5324bc6 Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Tue, 25 Feb 2025 12:55:44 +1300
Subject: [PATCH 027/212] fix: cve-2024-50338 - develop (#461)
---
binary-version.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/binary-version.json b/binary-version.json
index 914838f53..ff017d023 100644
--- a/binary-version.json
+++ b/binary-version.json
@@ -2,5 +2,5 @@
"docker": "v27.5.1",
"helm": "v3.17.0",
"kubectl": "v1.32.1",
- "mingit": "2.47.0.1"
+ "mingit": "2.48.1.1"
}
From dd98097897fc2dbe437aee24642d8f3a77c12194 Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Wed, 26 Feb 2025 13:00:25 +1300
Subject: [PATCH 028/212] fix(libstack): miss to read default .env file
[BE-11638] (#458)
---
pkg/libstack/compose/composeplugin.go | 22 +++++++++++++++++++---
1 file changed, 19 insertions(+), 3 deletions(-)
diff --git a/pkg/libstack/compose/composeplugin.go b/pkg/libstack/compose/composeplugin.go
index 3162c6ab7..c83685351 100644
--- a/pkg/libstack/compose/composeplugin.go
+++ b/pkg/libstack/compose/composeplugin.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"maps"
+ "os"
"path/filepath"
"slices"
"strconv"
@@ -87,7 +88,7 @@ func withComposeService(
return composeFn(composeService, nil)
}
- env, err := parseEnvironment(options)
+ env, err := parseEnvironment(options, filePaths)
if err != nil {
return err
}
@@ -326,7 +327,7 @@ func addServiceLabels(project *types.Project, oneOff bool, edgeStackID portainer
}
}
-func parseEnvironment(options libstack.Options) (map[string]string, error) {
+func parseEnvironment(options libstack.Options, filePaths []string) (map[string]string, error) {
env := make(map[string]string)
for _, envLine := range options.Env {
@@ -339,7 +340,22 @@ func parseEnvironment(options libstack.Options) (map[string]string, error) {
}
if options.EnvFilePath == "" {
- return env, nil
+ if len(filePaths) == 0 {
+ return env, nil
+ }
+
+ defaultDotEnv := filepath.Join(filepath.Dir(filePaths[0]), ".env")
+ s, err := os.Stat(defaultDotEnv)
+ if os.IsNotExist(err) {
+ return env, nil
+ }
+ if err != nil {
+ return env, err
+ }
+ if s.IsDir() {
+ return env, nil
+ }
+ options.EnvFilePath = defaultDotEnv
}
e, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath})
From 7759d762abf92ead75994dd7d0d14ce658e43fee Mon Sep 17 00:00:00 2001
From: James Player
Date: Wed, 26 Feb 2025 14:13:50 +1300
Subject: [PATCH 029/212] chore(react): Convert cluster details to react CE
(#466)
---
app/kubernetes/react/views/index.ts | 5 +
app/kubernetes/views/cluster/cluster.html | 33 ---
app/kubernetes/views/cluster/cluster.js | 8 -
.../views/cluster/clusterController.js | 139 ------------
app/react-tools/test-mocks.ts | 36 +++
.../FormControl/FormControl.tsx | 20 +-
.../ClusterResourceReservation.test.tsx | 214 ++++++++++++++++++
.../ClusterResourceReservation.tsx | 39 ++++
.../cluster/ClusterView/ClusterView.tsx | 33 +++
.../kubernetes/cluster/ClusterView/index.ts | 1 +
.../cluster/ClusterView/queries/index.ts | 3 +
.../queries/useClusterResourceLimitsQuery.ts | 49 ++++
.../useClusterResourceReservationQuery.ts | 25 ++
.../queries/useClusterResourceUsageQuery.ts | 46 ++++
.../useClusterResourceReservationData.tsx | 72 ++++++
.../cluster/HomeView/nodes.service.ts | 2 +-
.../components/ResourceReservation.tsx | 129 +++++++++++
.../components/ResourceUsageItem.tsx | 13 +-
app/react/kubernetes/metrics/types.ts | 8 +-
.../NamespaceResourceReservation.tsx | 45 ++++
.../ResourceQuotaFormSection.tsx | 4 +-
.../ResourceReservationUsage.tsx | 150 ------------
.../useNamespaceResourceReservationData.ts | 65 ++++++
app/react/kubernetes/utils.ts | 35 +++
24 files changed, 829 insertions(+), 345 deletions(-)
delete mode 100644 app/kubernetes/views/cluster/cluster.html
delete mode 100644 app/kubernetes/views/cluster/cluster.js
delete mode 100644 app/kubernetes/views/cluster/clusterController.js
create mode 100644 app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx
create mode 100644 app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.tsx
create mode 100644 app/react/kubernetes/cluster/ClusterView/ClusterView.tsx
create mode 100644 app/react/kubernetes/cluster/ClusterView/index.ts
create mode 100644 app/react/kubernetes/cluster/ClusterView/queries/index.ts
create mode 100644 app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceLimitsQuery.ts
create mode 100644 app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceReservationQuery.ts
create mode 100644 app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceUsageQuery.ts
create mode 100644 app/react/kubernetes/cluster/ClusterView/useClusterResourceReservationData.tsx
create mode 100644 app/react/kubernetes/components/ResourceReservation.tsx
rename app/react/kubernetes/{namespaces => }/components/ResourceUsageItem.tsx (75%)
create mode 100644 app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/NamespaceResourceReservation.tsx
delete mode 100644 app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/ResourceReservationUsage.tsx
create mode 100644 app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/useNamespaceResourceReservationData.ts
diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts
index a6c440de3..7913f7641 100644
--- a/app/kubernetes/react/views/index.ts
+++ b/app/kubernetes/react/views/index.ts
@@ -22,6 +22,7 @@ import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView';
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
+import { ClusterView } from '@/react/kubernetes/cluster/ClusterView';
export const viewsModule = angular
.module('portainer.kubernetes.react.views', [])
@@ -78,6 +79,10 @@ export const viewsModule = angular
[]
)
)
+ .component(
+ 'kubernetesClusterView',
+ r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), [])
+ )
.component(
'kubernetesConfigureView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), [])
diff --git a/app/kubernetes/views/cluster/cluster.html b/app/kubernetes/views/cluster/cluster.html
deleted file mode 100644
index 46acbf931..000000000
--- a/app/kubernetes/views/cluster/cluster.html
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
diff --git a/app/kubernetes/views/cluster/cluster.js b/app/kubernetes/views/cluster/cluster.js
deleted file mode 100644
index 708076037..000000000
--- a/app/kubernetes/views/cluster/cluster.js
+++ /dev/null
@@ -1,8 +0,0 @@
-angular.module('portainer.kubernetes').component('kubernetesClusterView', {
- templateUrl: './cluster.html',
- controller: 'KubernetesClusterController',
- controllerAs: 'ctrl',
- bindings: {
- endpoint: '<',
- },
-});
diff --git a/app/kubernetes/views/cluster/clusterController.js b/app/kubernetes/views/cluster/clusterController.js
deleted file mode 100644
index b8f6df290..000000000
--- a/app/kubernetes/views/cluster/clusterController.js
+++ /dev/null
@@ -1,139 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash-es';
-import filesizeParser from 'filesize-parser';
-import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
-import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
-import { getMetricsForAllNodes, getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics.ts';
-
-class KubernetesClusterController {
- /* @ngInject */
- constructor($async, $state, Notifications, LocalStorage, Authentication, KubernetesNodeService, KubernetesApplicationService, KubernetesEndpointService, EndpointService) {
- this.$async = $async;
- this.$state = $state;
- this.Authentication = Authentication;
- this.Notifications = Notifications;
- this.LocalStorage = LocalStorage;
- this.KubernetesNodeService = KubernetesNodeService;
- this.KubernetesApplicationService = KubernetesApplicationService;
- this.KubernetesEndpointService = KubernetesEndpointService;
- this.EndpointService = EndpointService;
-
- this.onInit = this.onInit.bind(this);
- this.getNodes = this.getNodes.bind(this);
- this.getNodesAsync = this.getNodesAsync.bind(this);
- this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
- this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
- this.hasResourceUsageAccess = this.hasResourceUsageAccess.bind(this);
- }
-
- async getEndpointsAsync() {
- try {
- const endpoints = await this.KubernetesEndpointService.get();
- const systemEndpoints = _.filter(endpoints, { Namespace: 'kube-system' });
- this.systemEndpoints = _.filter(systemEndpoints, (ep) => ep.HolderIdentity);
-
- const kubernetesEndpoint = _.find(endpoints, { Name: 'kubernetes' });
- if (kubernetesEndpoint && kubernetesEndpoint.Subsets) {
- const ips = _.flatten(_.map(kubernetesEndpoint.Subsets, 'Ips'));
- _.forEach(this.nodes, (node) => {
- node.Api = _.includes(ips, node.IPAddress);
- });
- }
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve environments');
- }
- }
-
- getEndpoints() {
- return this.$async(this.getEndpointsAsync);
- }
-
- async getNodesAsync() {
- try {
- const nodes = await this.KubernetesNodeService.get();
- _.forEach(nodes, (node) => (node.Memory = filesizeParser(node.Memory)));
- this.nodes = nodes;
- this.CPULimit = _.reduce(this.nodes, (acc, node) => node.CPU + acc, 0);
- this.CPULimit = Math.round(this.CPULimit * 10000) / 10000;
- this.MemoryLimit = _.reduce(this.nodes, (acc, node) => KubernetesResourceReservationHelper.megaBytesValue(node.Memory) + acc, 0);
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve nodes');
- }
- }
-
- getNodes() {
- return this.$async(this.getNodesAsync);
- }
-
- async getApplicationsAsync() {
- try {
- this.state.applicationsLoading = true;
-
- const applicationsResources = await getTotalResourcesForAllApplications(this.endpoint.Id);
- this.resourceReservation = new KubernetesResourceReservation();
- this.resourceReservation.CPU = Math.round(applicationsResources.CpuRequest / 1000);
- this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(applicationsResources.MemoryRequest);
-
- if (this.hasResourceUsageAccess()) {
- await this.getResourceUsage(this.endpoint.Id);
- }
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve applications');
- } finally {
- this.state.applicationsLoading = false;
- }
- }
-
- getApplications() {
- return this.$async(this.getApplicationsAsync);
- }
-
- async getResourceUsage(endpointId) {
- try {
- const nodeMetrics = await getMetricsForAllNodes(endpointId);
- const resourceUsageList = nodeMetrics.items.map((i) => i.usage);
- const clusterResourceUsage = resourceUsageList.reduce((total, u) => {
- total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu);
- total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory);
- return total;
- }, new KubernetesResourceReservation());
- this.resourceUsage = clusterResourceUsage;
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve cluster resource usage');
- }
- }
-
- /**
- * Check if resource usage stats can be displayed
- * @returns {boolean}
- */
- hasResourceUsageAccess() {
- return this.isAdmin && this.state.useServerMetrics;
- }
-
- async onInit() {
- this.endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
- this.isAdmin = this.Authentication.isAdmin();
- const useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
-
- this.state = {
- applicationsLoading: true,
- viewReady: false,
- useServerMetrics,
- };
-
- await this.getNodes();
- if (this.isAdmin) {
- await Promise.allSettled([this.getEndpoints(), this.getApplicationsAsync()]);
- }
-
- this.state.viewReady = true;
- }
-
- $onInit() {
- return this.$async(this.onInit);
- }
-}
-
-export default KubernetesClusterController;
-angular.module('portainer.kubernetes').controller('KubernetesClusterController', KubernetesClusterController);
diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts
index 556d7e4c2..20fe7dee3 100644
--- a/app/react-tools/test-mocks.ts
+++ b/app/react-tools/test-mocks.ts
@@ -1,4 +1,5 @@
import _ from 'lodash';
+import { QueryObserverResult } from '@tanstack/react-query';
import { Team } from '@/react/portainer/users/teams/types';
import { Role, User, UserId } from '@/portainer/users/types';
@@ -134,3 +135,38 @@ export function createMockEnvironment(): Environment {
},
};
}
+
+export function createMockQueryResult(
+ data: TData,
+ overrides?: Partial>
+) {
+ const defaultResult = {
+ data,
+ dataUpdatedAt: 0,
+ error: null,
+ errorUpdatedAt: 0,
+ failureCount: 0,
+ errorUpdateCount: 0,
+ failureReason: null,
+ isError: false,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isFetching: false,
+ isInitialLoading: false,
+ isLoading: false,
+ isLoadingError: false,
+ isPaused: false,
+ isPlaceholderData: false,
+ isPreviousData: false,
+ isRefetchError: false,
+ isRefetching: false,
+ isStale: false,
+ isSuccess: true,
+ refetch: async () => defaultResult,
+ remove: () => {},
+ status: 'success',
+ fetchStatus: 'idle',
+ };
+
+ return { ...defaultResult, ...overrides };
+}
diff --git a/app/react/components/form-components/FormControl/FormControl.tsx b/app/react/components/form-components/FormControl/FormControl.tsx
index 5f4c8727a..7c7151dd6 100644
--- a/app/react/components/form-components/FormControl/FormControl.tsx
+++ b/app/react/components/form-components/FormControl/FormControl.tsx
@@ -1,4 +1,4 @@
-import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
+import { PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import { Tooltip } from '@@/Tip/Tooltip';
@@ -10,10 +10,11 @@ export type Size = 'xsmall' | 'small' | 'medium' | 'large' | 'vertical';
export interface Props {
inputId?: string;
+ dataCy?: string;
label: ReactNode;
size?: Size;
- tooltip?: ComponentProps['message'];
- setTooltipHtmlMessage?: ComponentProps['setHtmlMessage'];
+ tooltip?: ReactNode;
+ setTooltipHtmlMessage?: boolean;
children: ReactNode;
errors?: ReactNode;
required?: boolean;
@@ -24,6 +25,7 @@ export interface Props {
export function FormControl({
inputId,
+ dataCy,
label,
size = 'small',
tooltip = '',
@@ -42,6 +44,7 @@ export function FormControl({
'form-group',
'after:clear-both after:table after:content-[""]' // to fix issues with float
)}
+ data-cy={dataCy}
>
-
- {isLoading &&
{loadingText} }
+
+ {isLoading && (
+ // 34px height to reduce layout shift when loading is complete
+
+ {loadingText}
+
+ )}
{!isLoading && children}
- {errors &&
{errors} }
+ {!!errors && !isLoading &&
{errors} }
);
diff --git a/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx b/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx
new file mode 100644
index 000000000..ebeecc6c4
--- /dev/null
+++ b/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx
@@ -0,0 +1,214 @@
+import { render, screen, within } from '@testing-library/react';
+import { HttpResponse } from 'msw';
+
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+import { server, http } from '@/setup-tests/server';
+import {
+ createMockEnvironment,
+ createMockQueryResult,
+} from '@/react-tools/test-mocks';
+
+import { ClusterResourceReservation } from './ClusterResourceReservation';
+
+const mockUseAuthorizations = vi.fn();
+const mockUseEnvironmentId = vi.fn(() => 3);
+const mockUseCurrentEnvironment = vi.fn();
+
+// Set up mock implementations for hooks
+vi.mock('@/react/hooks/useUser', () => ({
+ useAuthorizations: () => mockUseAuthorizations(),
+}));
+
+vi.mock('@/react/hooks/useEnvironmentId', () => ({
+ useEnvironmentId: () => mockUseEnvironmentId(),
+}));
+
+vi.mock('@/react/hooks/useCurrentEnvironment', () => ({
+ useCurrentEnvironment: () => mockUseCurrentEnvironment(),
+}));
+
+function renderComponent() {
+ const Wrapped = withTestQueryProvider(ClusterResourceReservation);
+ return render( );
+}
+
+describe('ClusterResourceReservation', () => {
+ beforeEach(() => {
+ // Set the return values for the hooks
+ mockUseAuthorizations.mockReturnValue({
+ authorized: true,
+ isLoading: false,
+ });
+
+ mockUseEnvironmentId.mockReturnValue(3);
+
+ const mockEnvironment = createMockEnvironment();
+ mockEnvironment.Kubernetes.Configuration.UseServerMetrics = true;
+ mockUseCurrentEnvironment.mockReturnValue(
+ createMockQueryResult(mockEnvironment)
+ );
+
+ // Setup default mock responses
+ server.use(
+ http.get('/api/endpoints/3/kubernetes/api/v1/nodes', () =>
+ HttpResponse.json({
+ items: [
+ {
+ status: {
+ allocatable: {
+ cpu: '4',
+ memory: '8Gi',
+ },
+ },
+ },
+ ],
+ })
+ ),
+ http.get('/api/kubernetes/3/metrics/nodes', () =>
+ HttpResponse.json({
+ items: [
+ {
+ usage: {
+ cpu: '2',
+ memory: '4Gi',
+ },
+ },
+ ],
+ })
+ ),
+ http.get('/api/kubernetes/3/metrics/applications_resources', () =>
+ HttpResponse.json({
+ CpuRequest: 1000,
+ MemoryRequest: '2Gi',
+ })
+ )
+ );
+ });
+
+ it('should display resource limits, reservations and usage when all APIs respond successfully', async () => {
+ renderComponent();
+
+ expect(
+ await within(await screen.findByTestId('memory-reservation')).findByText(
+ '2147 / 8589 MB - 25%'
+ )
+ ).toBeVisible();
+
+ expect(
+ await within(await screen.findByTestId('memory-usage')).findByText(
+ '4294 / 8589 MB - 50%'
+ )
+ ).toBeVisible();
+
+ expect(
+ await within(await screen.findByTestId('cpu-reservation')).findByText(
+ '1 / 4 - 25%'
+ )
+ ).toBeVisible();
+
+ expect(
+ await within(await screen.findByTestId('cpu-usage')).findByText(
+ '2 / 4 - 50%'
+ )
+ ).toBeVisible();
+ });
+
+ it('should not display resource usage if user does not have K8sClusterNodeR authorization', async () => {
+ mockUseAuthorizations.mockReturnValue({
+ authorized: false,
+ isLoading: false,
+ });
+
+ renderComponent();
+
+ // Should only show reservation bars
+ expect(
+ await within(await screen.findByTestId('memory-reservation')).findByText(
+ '2147 / 8589 MB - 25%'
+ )
+ ).toBeVisible();
+
+ expect(
+ await within(await screen.findByTestId('cpu-reservation')).findByText(
+ '1 / 4 - 25%'
+ )
+ ).toBeVisible();
+
+ // Usage bars should not be present
+ expect(screen.queryByTestId('memory-usage')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('cpu-usage')).not.toBeInTheDocument();
+ });
+
+ it('should not display resource usage if metrics server is not enabled', async () => {
+ const disabledMetricsEnvironment = createMockEnvironment();
+ disabledMetricsEnvironment.Kubernetes.Configuration.UseServerMetrics =
+ false;
+ mockUseCurrentEnvironment.mockReturnValue(
+ createMockQueryResult(disabledMetricsEnvironment)
+ );
+
+ renderComponent();
+
+ // Should only show reservation bars
+ expect(
+ await within(await screen.findByTestId('memory-reservation')).findByText(
+ '2147 / 8589 MB - 25%'
+ )
+ ).toBeVisible();
+
+ expect(
+ await within(await screen.findByTestId('cpu-reservation')).findByText(
+ '1 / 4 - 25%'
+ )
+ ).toBeVisible();
+
+ // Usage bars should not be present
+ expect(screen.queryByTestId('memory-usage')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('cpu-usage')).not.toBeInTheDocument();
+ });
+
+ it('should display warning if metrics server is enabled but usage query fails', async () => {
+ server.use(
+ http.get('/api/kubernetes/3/metrics/nodes', () => HttpResponse.error())
+ );
+
+ // Mock console.error so test logs are not polluted
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ renderComponent();
+
+ expect(
+ await within(await screen.findByTestId('memory-reservation')).findByText(
+ '2147 / 8589 MB - 25%'
+ )
+ ).toBeVisible();
+
+ expect(
+ await within(await screen.findByTestId('memory-usage')).findByText(
+ '0 / 8589 MB - 0%'
+ )
+ ).toBeVisible();
+
+ expect(
+ await within(await screen.findByTestId('cpu-reservation')).findByText(
+ '1 / 4 - 25%'
+ )
+ ).toBeVisible();
+
+ expect(
+ await within(await screen.findByTestId('cpu-usage')).findByText(
+ '0 / 4 - 0%'
+ )
+ ).toBeVisible();
+
+ // Should show the warning message
+ expect(
+ await screen.findByText(
+ /Resource usage is not currently available as Metrics Server is not responding/
+ )
+ ).toBeVisible();
+
+ // Restore console.error
+ vi.spyOn(console, 'error').mockRestore();
+ });
+});
diff --git a/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.tsx b/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.tsx
new file mode 100644
index 000000000..689ca8154
--- /dev/null
+++ b/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.tsx
@@ -0,0 +1,39 @@
+import { Widget, WidgetBody } from '@/react/components/Widget';
+import { ResourceReservation } from '@/react/kubernetes/components/ResourceReservation';
+
+import { useClusterResourceReservationData } from './useClusterResourceReservationData';
+
+export function ClusterResourceReservation() {
+ // Load all data required for this component
+ const {
+ cpuLimit,
+ memoryLimit,
+ isLoading,
+ displayResourceUsage,
+ resourceUsage,
+ resourceReservation,
+ displayWarning,
+ } = useClusterResourceReservationData();
+
+ return (
+
+ );
+}
diff --git a/app/react/kubernetes/cluster/ClusterView/ClusterView.tsx b/app/react/kubernetes/cluster/ClusterView/ClusterView.tsx
new file mode 100644
index 000000000..57cd3ca4f
--- /dev/null
+++ b/app/react/kubernetes/cluster/ClusterView/ClusterView.tsx
@@ -0,0 +1,33 @@
+import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
+import { PageHeader } from '@/react/components/PageHeader';
+import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
+
+import { ClusterResourceReservation } from './ClusterResourceReservation';
+
+export function ClusterView() {
+ const { data: environment } = useCurrentEnvironment();
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/react/kubernetes/cluster/ClusterView/index.ts b/app/react/kubernetes/cluster/ClusterView/index.ts
new file mode 100644
index 000000000..d9b624cc4
--- /dev/null
+++ b/app/react/kubernetes/cluster/ClusterView/index.ts
@@ -0,0 +1 @@
+export { ClusterView } from './ClusterView';
diff --git a/app/react/kubernetes/cluster/ClusterView/queries/index.ts b/app/react/kubernetes/cluster/ClusterView/queries/index.ts
new file mode 100644
index 000000000..f4d86933b
--- /dev/null
+++ b/app/react/kubernetes/cluster/ClusterView/queries/index.ts
@@ -0,0 +1,3 @@
+export * from './useClusterResourceLimitsQuery';
+export * from './useClusterResourceReservationQuery';
+export * from './useClusterResourceUsageQuery';
diff --git a/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceLimitsQuery.ts b/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceLimitsQuery.ts
new file mode 100644
index 000000000..795ab595e
--- /dev/null
+++ b/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceLimitsQuery.ts
@@ -0,0 +1,49 @@
+import { round, reduce } from 'lodash';
+import filesizeParser from 'filesize-parser';
+import { useQuery } from '@tanstack/react-query';
+import { Node } from 'kubernetes-types/core/v1';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { withGlobalError } from '@/react-tools/react-query';
+import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
+import { parseCpu } from '@/react/kubernetes/utils';
+import { getNodes } from '@/react/kubernetes/cluster/HomeView/nodes.service';
+
+export function useClusterResourceLimitsQuery(environmentId: EnvironmentId) {
+ return useQuery(
+ [environmentId, 'clusterResourceLimits'],
+ async () => getNodes(environmentId),
+ {
+ ...withGlobalError('Unable to retrieve resource limit data', 'Failure'),
+ enabled: !!environmentId,
+ select: aggregateResourceLimits,
+ }
+ );
+}
+
+/**
+ * Processes node data to calculate total CPU and memory limits for the cluster
+ * and sets the state for memory limit in MB and CPU limit rounded to 3 decimal places.
+ */
+function aggregateResourceLimits(nodes: Node[]) {
+ const processedNodes = nodes.map((node) => ({
+ ...node,
+ memory: filesizeParser(node.status?.allocatable?.memory ?? ''),
+ cpu: parseCpu(node.status?.allocatable?.cpu ?? ''),
+ }));
+
+ return {
+ nodes: processedNodes,
+ memoryLimit: reduce(
+ processedNodes,
+ (acc, node) =>
+ KubernetesResourceReservationHelper.megaBytesValue(node.memory || 0) +
+ acc,
+ 0
+ ),
+ cpuLimit: round(
+ reduce(processedNodes, (acc, node) => (node.cpu || 0) + acc, 0),
+ 3
+ ),
+ };
+}
diff --git a/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceReservationQuery.ts b/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceReservationQuery.ts
new file mode 100644
index 000000000..50c68fd7d
--- /dev/null
+++ b/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceReservationQuery.ts
@@ -0,0 +1,25 @@
+import { useQuery } from '@tanstack/react-query';
+import { Node } from 'kubernetes-types/core/v1';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics';
+import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
+
+export function useClusterResourceReservationQuery(
+ environmentId: EnvironmentId,
+ nodes: Node[]
+) {
+ return useQuery(
+ [environmentId, 'clusterResourceReservation'],
+ () => getTotalResourcesForAllApplications(environmentId),
+ {
+ enabled: !!environmentId && nodes.length > 0,
+ select: (data) => ({
+ cpu: data.CpuRequest / 1000,
+ memory: KubernetesResourceReservationHelper.megaBytesValue(
+ data.MemoryRequest
+ ),
+ }),
+ }
+ );
+}
diff --git a/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceUsageQuery.ts b/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceUsageQuery.ts
new file mode 100644
index 000000000..a1e65b065
--- /dev/null
+++ b/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceUsageQuery.ts
@@ -0,0 +1,46 @@
+import { useQuery } from '@tanstack/react-query';
+import { Node } from 'kubernetes-types/core/v1';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { getMetricsForAllNodes } from '@/react/kubernetes/metrics/metrics';
+import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
+import { withGlobalError } from '@/react-tools/react-query';
+import { NodeMetrics } from '@/react/kubernetes/metrics/types';
+
+export function useClusterResourceUsageQuery(
+ environmentId: EnvironmentId,
+ serverMetricsEnabled: boolean,
+ authorized: boolean,
+ nodes: Node[]
+) {
+ return useQuery(
+ [environmentId, 'clusterResourceUsage'],
+ () => getMetricsForAllNodes(environmentId),
+ {
+ enabled:
+ authorized &&
+ serverMetricsEnabled &&
+ !!environmentId &&
+ nodes.length > 0,
+ select: aggregateResourceUsage,
+ ...withGlobalError('Unable to retrieve resource usage data.', 'Failure'),
+ }
+ );
+}
+
+function aggregateResourceUsage(data: NodeMetrics) {
+ return data.items.reduce(
+ (total, item) => ({
+ cpu:
+ total.cpu +
+ KubernetesResourceReservationHelper.parseCPU(item.usage.cpu),
+ memory:
+ total.memory +
+ KubernetesResourceReservationHelper.megaBytesValue(item.usage.memory),
+ }),
+ {
+ cpu: 0,
+ memory: 0,
+ }
+ );
+}
diff --git a/app/react/kubernetes/cluster/ClusterView/useClusterResourceReservationData.tsx b/app/react/kubernetes/cluster/ClusterView/useClusterResourceReservationData.tsx
new file mode 100644
index 000000000..276c7ff8a
--- /dev/null
+++ b/app/react/kubernetes/cluster/ClusterView/useClusterResourceReservationData.tsx
@@ -0,0 +1,72 @@
+import { useAuthorizations } from '@/react/hooks/useUser';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { getSafeValue } from '@/react/kubernetes/utils';
+import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
+
+import {
+ useClusterResourceLimitsQuery,
+ useClusterResourceReservationQuery,
+ useClusterResourceUsageQuery,
+} from './queries';
+
+export function useClusterResourceReservationData() {
+ const { data: environment } = useCurrentEnvironment();
+ const environmentId = useEnvironmentId();
+
+ // Check if server metrics is enabled
+ const serverMetricsEnabled =
+ environment?.Kubernetes?.Configuration?.UseServerMetrics || false;
+
+ // User needs to have K8sClusterNodeR authorization to view resource usage data
+ const { authorized: hasK8sClusterNodeR } = useAuthorizations(
+ ['K8sClusterNodeR'],
+ undefined,
+ true
+ );
+
+ // Get resource limits for the cluster
+ const { data: resourceLimits, isLoading: isResourceLimitLoading } =
+ useClusterResourceLimitsQuery(environmentId);
+
+ // Get resource reservation info for the cluster
+ const {
+ data: resourceReservation,
+ isFetching: isResourceReservationLoading,
+ } = useClusterResourceReservationQuery(
+ environmentId,
+ resourceLimits?.nodes || []
+ );
+
+ // Get resource usage info for the cluster
+ const {
+ data: resourceUsage,
+ isFetching: isResourceUsageLoading,
+ isError: isResourceUsageError,
+ } = useClusterResourceUsageQuery(
+ environmentId,
+ serverMetricsEnabled,
+ hasK8sClusterNodeR,
+ resourceLimits?.nodes || []
+ );
+
+ return {
+ memoryLimit: getSafeValue(resourceLimits?.memoryLimit || 0),
+ cpuLimit: getSafeValue(resourceLimits?.cpuLimit || 0),
+ displayResourceUsage: hasK8sClusterNodeR && serverMetricsEnabled,
+ resourceUsage: {
+ cpu: getSafeValue(resourceUsage?.cpu || 0),
+ memory: getSafeValue(resourceUsage?.memory || 0),
+ },
+ resourceReservation: {
+ cpu: getSafeValue(resourceReservation?.cpu || 0),
+ memory: getSafeValue(resourceReservation?.memory || 0),
+ },
+ isLoading:
+ isResourceLimitLoading ||
+ isResourceReservationLoading ||
+ isResourceUsageLoading,
+ // Display warning if server metrics isn't responding but should be
+ displayWarning:
+ hasK8sClusterNodeR && serverMetricsEnabled && isResourceUsageError,
+ };
+}
diff --git a/app/react/kubernetes/cluster/HomeView/nodes.service.ts b/app/react/kubernetes/cluster/HomeView/nodes.service.ts
index fdbd670c4..9afc5ed7f 100644
--- a/app/react/kubernetes/cluster/HomeView/nodes.service.ts
+++ b/app/react/kubernetes/cluster/HomeView/nodes.service.ts
@@ -45,7 +45,7 @@ export function useNodeQuery(environmentId: EnvironmentId, nodeName: string) {
}
// getNodes is used to get a list of nodes using the kubernetes API
-async function getNodes(environmentId: EnvironmentId) {
+export async function getNodes(environmentId: EnvironmentId) {
try {
const { data: nodeList } = await axios.get(
`/endpoints/${environmentId}/kubernetes/api/v1/nodes`
diff --git a/app/react/kubernetes/components/ResourceReservation.tsx b/app/react/kubernetes/components/ResourceReservation.tsx
new file mode 100644
index 000000000..016eb3ea2
--- /dev/null
+++ b/app/react/kubernetes/components/ResourceReservation.tsx
@@ -0,0 +1,129 @@
+import { round } from 'lodash';
+import { AlertTriangle } from 'lucide-react';
+
+import { FormSectionTitle } from '@/react/components/form-components/FormSectionTitle';
+import { TextTip } from '@/react/components/Tip/TextTip';
+import { ResourceUsageItem } from '@/react/kubernetes/components/ResourceUsageItem';
+import { getPercentageString, getSafeValue } from '@/react/kubernetes/utils';
+
+import { Icon } from '@@/Icon';
+
+interface ResourceMetrics {
+ cpu: number;
+ memory: number;
+}
+
+interface Props {
+ displayResourceUsage: boolean;
+ resourceReservation: ResourceMetrics;
+ resourceUsage: ResourceMetrics;
+ cpuLimit: number;
+ memoryLimit: number;
+ description: string;
+ isLoading?: boolean;
+ title?: string;
+ displayWarning?: boolean;
+ warningMessage?: string;
+}
+
+export function ResourceReservation({
+ displayResourceUsage,
+ resourceReservation,
+ resourceUsage,
+ cpuLimit,
+ memoryLimit,
+ description,
+ title = 'Resource reservation',
+ isLoading = false,
+ displayWarning = false,
+ warningMessage = '',
+}: Props) {
+ const memoryReservationAnnotation = `${getSafeValue(
+ resourceReservation.memory
+ )} / ${memoryLimit} MB ${getPercentageString(
+ resourceReservation.memory,
+ memoryLimit
+ )}`;
+
+ const memoryUsageAnnotation = `${getSafeValue(
+ resourceUsage.memory
+ )} / ${memoryLimit} MB ${getPercentageString(
+ resourceUsage.memory,
+ memoryLimit
+ )}`;
+
+ const cpuReservationAnnotation = `${round(
+ getSafeValue(resourceReservation.cpu),
+ 2
+ )} / ${round(getSafeValue(cpuLimit), 2)} ${getPercentageString(
+ resourceReservation.cpu,
+ cpuLimit
+ )}`;
+
+ const cpuUsageAnnotation = `${round(
+ getSafeValue(resourceUsage.cpu),
+ 2
+ )} / ${round(getSafeValue(cpuLimit), 2)} ${getPercentageString(
+ resourceUsage.cpu,
+ cpuLimit
+ )}`;
+
+ return (
+ <>
+ {title}
+
+ {description}
+
+
+ {memoryLimit > 0 && (
+
+ )}
+ {displayResourceUsage && memoryLimit > 0 && (
+
+ )}
+ {cpuLimit > 0 && (
+
+ )}
+ {displayResourceUsage && cpuLimit > 0 && (
+
+ )}
+ {displayWarning && (
+
+
+
+ {warningMessage}
+
+
+ )}
+
+ >
+ );
+}
diff --git a/app/react/kubernetes/namespaces/components/ResourceUsageItem.tsx b/app/react/kubernetes/components/ResourceUsageItem.tsx
similarity index 75%
rename from app/react/kubernetes/namespaces/components/ResourceUsageItem.tsx
rename to app/react/kubernetes/components/ResourceUsageItem.tsx
index 647464ba9..5dcea701d 100644
--- a/app/react/kubernetes/namespaces/components/ResourceUsageItem.tsx
+++ b/app/react/kubernetes/components/ResourceUsageItem.tsx
@@ -1,11 +1,13 @@
-import { ProgressBar } from '@@/ProgressBar';
import { FormControl } from '@@/form-components/FormControl';
+import { ProgressBar } from '@@/ProgressBar';
interface ResourceUsageItemProps {
value: number;
total: number;
annotation?: React.ReactNode;
label: string;
+ isLoading?: boolean;
+ dataCy?: string;
}
export function ResourceUsageItem({
@@ -13,9 +15,16 @@ export function ResourceUsageItem({
total,
annotation,
label,
+ isLoading = false,
+ dataCy,
}: ResourceUsageItemProps) {
return (
-
+
+ );
+}
diff --git a/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx
index f3412aaba..eceb5a062 100644
--- a/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx
@@ -13,8 +13,8 @@ import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput';
import { useClusterResourceLimitsQuery } from '../../../queries/useResourceLimitsQuery';
-import { ResourceReservationUsage } from './ResourceReservationUsage';
import { ResourceQuotaFormValues } from './types';
+import { NamespaceResourceReservation } from './NamespaceResourceReservation';
interface Props {
values: ResourceQuotaFormValues;
@@ -128,7 +128,7 @@ export function ResourceQuotaFormSection({
)}
{namespaceName && isEdit && (
-
- Resource reservation
-
- Resource reservation represents the total amount of resource assigned to
- all the applications deployed inside this namespace.
-
- {!!usedResourceQuota && memoryQuota > 0 && (
-
- )}
- {!!namespaceMetrics && memoryQuota > 0 && (
-
- )}
- {!!usedResourceQuota && cpuQuota > 0 && (
-
- )}
- {!!namespaceMetrics && cpuQuota > 0 && (
-
- )}
- >
- );
-}
-
-function getSafeValue(value: number | string) {
- const valueNumber = Number(value);
- if (Number.isNaN(valueNumber)) {
- return 0;
- }
- return valueNumber;
-}
-
-/**
- * Returns the percentage of the value over the total.
- * @param value - The value to calculate the percentage for.
- * @param total - The total value to compare the percentage to.
- * @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
- */
-function getPercentageString(value: number, total?: number | string) {
- const totalNumber = Number(total);
- if (
- totalNumber === 0 ||
- total === undefined ||
- total === '' ||
- Number.isNaN(totalNumber)
- ) {
- return '';
- }
- if (value > totalNumber) {
- return '- Exceeded';
- }
- return `- ${Math.round((value / totalNumber) * 100)}%`;
-}
-
-/**
- * Aggregates the resource usage of all the containers in the namespace.
- * @param podMetricsList - List of pod metrics
- * @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
- */
-function aggregatePodUsage(podMetricsList: PodMetrics) {
- const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
- i.containers.map((c) => c.usage)
- );
- const namespaceResourceUsage = containerResourceUsageList.reduce(
- (total, usage) => ({
- cpu: total.cpu + parseCPU(usage.cpu),
- memory: total.memory + megaBytesValue(usage.memory),
- }),
- { cpu: 0, memory: 0 }
- );
- namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
- return namespaceResourceUsage;
-}
diff --git a/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/useNamespaceResourceReservationData.ts b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/useNamespaceResourceReservationData.ts
new file mode 100644
index 000000000..cdf269d49
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/useNamespaceResourceReservationData.ts
@@ -0,0 +1,65 @@
+import { round } from 'lodash';
+
+import { getSafeValue } from '@/react/kubernetes/utils';
+import { PodMetrics } from '@/react/kubernetes/metrics/types';
+import { useMetricsForNamespace } from '@/react/kubernetes/metrics/queries/useMetricsForNamespace';
+import {
+ megaBytesValue,
+ parseCPU,
+} from '@/react/kubernetes/namespaces/resourceQuotaUtils';
+
+import { useResourceQuotaUsed } from './useResourceQuotaUsed';
+import { ResourceQuotaFormValues } from './types';
+
+export function useNamespaceResourceReservationData(
+ environmentId: number,
+ namespaceName: string,
+ resourceQuotaValues: ResourceQuotaFormValues
+) {
+ const { data: quota, isLoading: isQuotaLoading } = useResourceQuotaUsed(
+ environmentId,
+ namespaceName
+ );
+ const { data: metrics, isLoading: isMetricsLoading } = useMetricsForNamespace(
+ environmentId,
+ namespaceName,
+ {
+ select: aggregatePodUsage,
+ }
+ );
+
+ return {
+ cpuLimit: Number(resourceQuotaValues.cpu) || 0,
+ memoryLimit: Number(resourceQuotaValues.memory) || 0,
+ displayResourceUsage: !!metrics,
+ resourceReservation: {
+ cpu: getSafeValue(quota?.cpu || 0),
+ memory: getSafeValue(quota?.memory || 0),
+ },
+ resourceUsage: {
+ cpu: getSafeValue(metrics?.cpu || 0),
+ memory: getSafeValue(metrics?.memory || 0),
+ },
+ isLoading: isQuotaLoading || isMetricsLoading,
+ };
+}
+
+/**
+ * Aggregates the resource usage of all the containers in the namespace.
+ * @param podMetricsList - List of pod metrics
+ * @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
+ */
+function aggregatePodUsage(podMetricsList: PodMetrics) {
+ const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
+ i.containers.map((c) => c.usage)
+ );
+ const namespaceResourceUsage = containerResourceUsageList.reduce(
+ (total, usage) => ({
+ cpu: total.cpu + parseCPU(usage.cpu),
+ memory: total.memory + megaBytesValue(usage.memory),
+ }),
+ { cpu: 0, memory: 0 }
+ );
+ namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
+ return namespaceResourceUsage;
+}
diff --git a/app/react/kubernetes/utils.ts b/app/react/kubernetes/utils.ts
index 644debad0..e8dc7c9a6 100644
--- a/app/react/kubernetes/utils.ts
+++ b/app/react/kubernetes/utils.ts
@@ -20,3 +20,38 @@ export function prepareAnnotations(annotations?: Annotation[]) {
);
return result;
}
+
+/**
+ * Returns the safe value of the given number or string.
+ * @param value - The value to get the safe value for.
+ * @returns The safe value of the given number or string.
+ */
+export function getSafeValue(value: number | string) {
+ const valueNumber = Number(value);
+ if (Number.isNaN(valueNumber)) {
+ return 0;
+ }
+ return valueNumber;
+}
+
+/**
+ * Returns the percentage of the value over the total.
+ * @param value - The value to calculate the percentage for.
+ * @param total - The total value to compare the percentage to.
+ * @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
+ */
+export function getPercentageString(value: number, total?: number | string) {
+ const totalNumber = Number(total);
+ if (
+ totalNumber === 0 ||
+ total === undefined ||
+ total === '' ||
+ Number.isNaN(totalNumber)
+ ) {
+ return '';
+ }
+ if (value > totalNumber) {
+ return '- Exceeded';
+ }
+ return `- ${Math.round((value / totalNumber) * 100)}%`;
+}
From a554a8c49f1a9acfff0219a4e523103dc68f19c5 Mon Sep 17 00:00:00 2001
From: Anthony Lapenna
Date: Wed, 26 Feb 2025 16:10:02 +1300
Subject: [PATCH 030/212] api: remove server-ce swagger.json (#467)
---
api/swagger.yaml | 6114 ----------------------------------------------
1 file changed, 6114 deletions(-)
delete mode 100644 api/swagger.yaml
diff --git a/api/swagger.yaml b/api/swagger.yaml
deleted file mode 100644
index e5d6eabea..000000000
--- a/api/swagger.yaml
+++ /dev/null
@@ -1,6114 +0,0 @@
-basePath: /api
-definitions:
- auth.authenticatePayload:
- properties:
- password:
- description: Password
- example: mypassword
- type: string
- username:
- description: Username
- example: admin
- type: string
- required:
- - password
- - username
- type: object
- auth.authenticateResponse:
- properties:
- jwt:
- description: JWT token used to authenticate against the API
- example: abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB
- type: string
- type: object
- auth.oauthPayload:
- properties:
- code:
- description: OAuth code returned from OAuth Provided
- type: string
- type: object
- customtemplates.customTemplateFromFileContentPayload:
- properties:
- description:
- description: Description of the template
- example: High performance web server
- type: string
- fileContent:
- description: Content of stack file
- type: string
- logo:
- description: URL of the template's logo
- example: https://cloudinovasi.id/assets/img/logos/nginx.png
- type: string
- note:
- description: A note that will be displayed in the UI. Supports HTML content
- example: This is my custom template
- type: string
- platform:
- description: |-
- Platform associated to the template.
- Valid values are: 1 - 'linux', 2 - 'windows'
- enum:
- - 1
- - 2
- example: 1
- type: integer
- title:
- description: Title of the template
- example: Nginx
- type: string
- type:
- description: Type of created stack (1 - swarm, 2 - compose)
- enum:
- - 1
- - 2
- example: 1
- type: integer
- required:
- - description
- - fileContent
- - platform
- - title
- - type
- type: object
- customtemplates.customTemplateFromGitRepositoryPayload:
- properties:
- composeFilePathInRepository:
- default: docker-compose.yml
- description: Path to the Stack file inside the Git repository
- example: docker-compose.yml
- type: string
- description:
- description: Description of the template
- example: High performance web server
- type: string
- logo:
- description: URL of the template's logo
- example: https://cloudinovasi.id/assets/img/logos/nginx.png
- type: string
- note:
- description: A note that will be displayed in the UI. Supports HTML content
- example: This is my custom template
- type: string
- platform:
- description: |-
- Platform associated to the template.
- Valid values are: 1 - 'linux', 2 - 'windows'
- enum:
- - 1
- - 2
- example: 1
- type: integer
- repositoryAuthentication:
- description: Use basic authentication to clone the Git repository
- example: true
- type: boolean
- repositoryPassword:
- description: Password used in basic authentication. Required when RepositoryAuthentication
- is true.
- example: myGitPassword
- type: string
- repositoryReferenceName:
- description: Reference name of a Git repository hosting the Stack file
- example: refs/heads/master
- type: string
- repositoryURL:
- description: URL of a Git repository hosting the Stack file
- example: https://github.com/openfaas/faas
- type: string
- repositoryUsername:
- description: Username used in basic authentication. Required when RepositoryAuthentication
- is true.
- example: myGitUsername
- type: string
- title:
- description: Title of the template
- example: Nginx
- type: string
- type:
- description: Type of created stack (1 - swarm, 2 - compose)
- enum:
- - 1
- - 2
- example: 1
- type: integer
- required:
- - description
- - platform
- - repositoryURL
- - title
- - type
- type: object
- customtemplates.customTemplateUpdatePayload:
- properties:
- description:
- description: Description of the template
- example: High performance web server
- type: string
- fileContent:
- description: Content of stack file
- type: string
- logo:
- description: URL of the template's logo
- example: https://cloudinovasi.id/assets/img/logos/nginx.png
- type: string
- note:
- description: A note that will be displayed in the UI. Supports HTML content
- example: This is my custom template
- type: string
- platform:
- description: |-
- Platform associated to the template.
- Valid values are: 1 - 'linux', 2 - 'windows'
- enum:
- - 1
- - 2
- example: 1
- type: integer
- title:
- description: Title of the template
- example: Nginx
- type: string
- type:
- description: Type of created stack (1 - swarm, 2 - compose)
- enum:
- - 1
- - 2
- example: 1
- type: integer
- required:
- - description
- - fileContent
- - platform
- - title
- - type
- type: object
- customtemplates.fileResponse:
- properties:
- fileContent:
- type: string
- type: object
- dockerhub.dockerhubUpdatePayload:
- properties:
- authentication:
- description: Enable authentication against DockerHub
- example: false
- type: boolean
- password:
- description: Password used to authenticate against the DockerHub
- example: hub_password
- type: string
- username:
- description: Username used to authenticate against the DockerHub
- example: hub_user
- type: string
- required:
- - authentication
- - password
- - username
- type: object
- edgegroups.edgeGroupCreatePayload:
- properties:
- dynamic:
- type: boolean
- endpoints:
- items:
- type: integer
- type: array
- name:
- type: string
- partialMatch:
- type: boolean
- tagIDs:
- items:
- description: Tag identifier
- example: 1
- type: integer
- type: array
- type: object
- edgegroups.edgeGroupUpdatePayload:
- properties:
- dynamic:
- type: boolean
- endpoints:
- items:
- type: integer
- type: array
- name:
- type: string
- partialMatch:
- type: boolean
- tagIDs:
- items:
- description: Tag identifier
- example: 1
- type: integer
- type: array
- type: object
- edgejobs.edgeJobCreateFromFileContentPayload:
- properties:
- cronExpression:
- type: string
- endpoints:
- items:
- type: integer
- type: array
- fileContent:
- type: string
- name:
- type: string
- recurring:
- type: boolean
- type: object
- edgejobs.edgeJobCreateFromFilePayload:
- properties:
- cronExpression:
- type: string
- endpoints:
- items:
- type: integer
- type: array
- file:
- items:
- type: integer
- type: array
- name:
- type: string
- recurring:
- type: boolean
- type: object
- edgejobs.edgeJobFileResponse:
- properties:
- FileContent:
- type: string
- type: object
- edgejobs.edgeJobUpdatePayload:
- properties:
- cronExpression:
- type: string
- endpoints:
- items:
- type: integer
- type: array
- fileContent:
- type: string
- name:
- type: string
- recurring:
- type: boolean
- type: object
- edgejobs.fileResponse:
- properties:
- FileContent:
- type: string
- type: object
- edgejobs.taskContainer:
- properties:
- EndpointId:
- type: integer
- Id:
- type: string
- LogsStatus:
- type: integer
- type: object
- edgestacks.stackFileResponse:
- properties:
- StackFileContent:
- type: string
- type: object
- edgestacks.swarmStackFromFileContentPayload:
- properties:
- edgeGroups:
- items:
- description: EdgeGroup Identifier
- example: 1
- type: integer
- type: array
- name:
- type: string
- stackFileContent:
- type: string
- type: object
- edgestacks.swarmStackFromFileUploadPayload:
- properties:
- edgeGroups:
- items:
- description: EdgeGroup Identifier
- example: 1
- type: integer
- type: array
- name:
- type: string
- stackFileContent:
- items:
- type: integer
- type: array
- type: object
- edgestacks.swarmStackFromGitRepositoryPayload:
- properties:
- composeFilePathInRepository:
- type: string
- edgeGroups:
- items:
- description: EdgeGroup Identifier
- example: 1
- type: integer
- type: array
- name:
- type: string
- repositoryAuthentication:
- type: boolean
- repositoryPassword:
- type: string
- repositoryReferenceName:
- type: string
- repositoryURL:
- type: string
- repositoryUsername:
- type: string
- type: object
- edgestacks.updateEdgeStackPayload:
- properties:
- edgeGroups:
- items:
- description: EdgeGroup Identifier
- example: 1
- type: integer
- type: array
- prune:
- type: boolean
- stackFileContent:
- type: string
- version:
- type: integer
- type: object
- endpointedge.configResponse:
- properties:
- name:
- type: string
- prune:
- type: boolean
- stackFileContent:
- type: string
- type: object
- endpointgroups.endpointGroupCreatePayload:
- properties:
- associatedEndpoints:
- description: List of endpoint identifiers that will be part of this group
- example:
- - 1
- - 3
- items:
- type: integer
- type: array
- description:
- description: Endpoint group description
- example: description
- type: string
- name:
- description: Endpoint group name
- example: my-endpoint-group
- type: string
- tagIDs:
- description: List of tag identifiers to which this endpoint group is associated
- example:
- - 1
- - 2
- items:
- description: Tag identifier
- example: 1
- type: integer
- type: array
- required:
- - name
- type: object
- endpointgroups.endpointGroupUpdatePayload:
- properties:
- description:
- description: Endpoint group description
- example: description
- type: string
- name:
- description: Endpoint group name
- example: my-endpoint-group
- type: string
- tagIDs:
- description: List of tag identifiers associated to the endpoint group
- example:
- - 3
- - 4
- items:
- description: Tag identifier
- example: 1
- type: integer
- type: array
- teamAccessPolicies:
- $ref: '#/definitions/portainer.TeamAccessPolicies'
- userAccessPolicies:
- $ref: '#/definitions/portainer.UserAccessPolicies'
- type: object
- endpoints.edgeJobResponse:
- properties:
- CollectLogs:
- description: Whether to collect logs
- example: true
- type: boolean
- CronExpression:
- description: A cron expression to schedule this job
- example: '* * * * *'
- type: string
- Id:
- description: EdgeJob Identifier
- example: 2
- type: integer
- Script:
- description: Script to run
- example: echo hello
- type: string
- Version:
- description: Version of this EdgeJob
- example: 2
- type: integer
- type: object
- endpoints.endpointEdgeStatusInspectResponse:
- properties:
- checkin:
- description: The current value of CheckinInterval
- example: 5
- type: integer
- credentials:
- type: string
- port:
- description: The tunnel port
- example: 8732
- type: integer
- schedules:
- description: List of requests for jobs to run on the endpoint
- items:
- $ref: '#/definitions/endpoints.edgeJobResponse'
- type: array
- stacks:
- description: List of stacks to be deployed on the endpoints
- items:
- $ref: '#/definitions/endpoints.stackStatusResponse'
- type: array
- status:
- description: Status represents the endpoint status
- example: REQUIRED
- type: string
- type: object
- endpoints.endpointUpdatePayload:
- properties:
- azureApplicationID:
- description: Azure application ID
- example: eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4
- type: string
- azureAuthenticationKey:
- description: Azure authentication key
- example: cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=
- type: string
- azureTenantID:
- description: Azure tenant ID
- example: 34ddc78d-4fel-2358-8cc1-df84c8o839f5
- type: string
- edgeCheckinInterval:
- description: The check in interval for edge agent (in seconds)
- example: 5
- type: integer
- groupID:
- description: Group identifier
- example: 1
- type: integer
- kubernetes:
- $ref: '#/definitions/portainer.KubernetesData'
- description: Associated Kubernetes data
- name:
- description: Name that will be used to identify this endpoint
- example: my-endpoint
- type: string
- publicURL:
- description: |-
- URL or IP address where exposed containers will be reachable.\
- Defaults to URL if not specified
- example: docker.mydomain.tld:2375
- type: string
- status:
- description: The status of the endpoint (1 - up, 2 - down)
- example: 1
- type: integer
- tagIDs:
- description: List of tag identifiers to which this endpoint is associated
- example:
- - 1
- - 2
- items:
- description: Tag identifier
- example: 1
- type: integer
- type: array
- teamAccessPolicies:
- $ref: '#/definitions/portainer.TeamAccessPolicies'
- tls:
- description: Require TLS to connect against this endpoint
- example: true
- type: boolean
- tlsskipClientVerify:
- description: Skip client verification when using TLS
- example: false
- type: boolean
- tlsskipVerify:
- description: Skip server verification when using TLS
- example: false
- type: boolean
- url:
- description: URL or IP address of a Docker host
- example: docker.mydomain.tld:2375
- type: string
- userAccessPolicies:
- $ref: '#/definitions/portainer.UserAccessPolicies'
- type: object
- endpoints.stackStatusResponse:
- properties:
- id:
- description: EdgeStack Identifier
- example: 1
- type: integer
- version:
- description: Version of this stack
- example: 3
- type: integer
- type: object
- motd.motdResponse:
- properties:
- ContentLayout:
- additionalProperties:
- type: string
- type: object
- Hash:
- items:
- type: integer
- type: array
- Message:
- type: string
- Style:
- type: string
- Title:
- type: string
- type: object
- portainer.AccessPolicy:
- properties:
- RoleId:
- description: Role identifier. Reference the role that will be associated to
- this access policy
- example: 1
- type: integer
- type: object
- portainer.Authorizations:
- additionalProperties:
- type: boolean
- type: object
- portainer.AzureCredentials:
- properties:
- ApplicationID:
- description: Azure application ID
- example: eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4
- type: string
- AuthenticationKey:
- description: Azure authentication key
- example: cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=
- type: string
- TenantID:
- description: Azure tenant ID
- example: 34ddc78d-4fel-2358-8cc1-df84c8o839f5
- type: string
- type: object
- portainer.CustomTemplate:
- properties:
- CreatedByUserId:
- description: User identifier who created this template
- example: 3
- type: integer
- Description:
- description: Description of the template
- example: High performance web server
- type: string
- EntryPoint:
- description: Path to the Stack file
- example: docker-compose.yml
- type: string
- Id:
- description: CustomTemplate Identifier
- example: 1
- type: integer
- Logo:
- description: URL of the template's logo
- example: https://cloudinovasi.id/assets/img/logos/nginx.png
- type: string
- Note:
- description: A note that will be displayed in the UI. Supports HTML content
- example: This is my custom template
- type: string
- Platform:
- description: |-
- Platform associated to the template.
- Valid values are: 1 - 'linux', 2 - 'windows'
- enum:
- - 1
- - 2
- example: 1
- type: integer
- ProjectPath:
- description: Path on disk to the repository hosting the Stack file
- example: /data/custom_template/3
- type: string
- ResourceControl:
- $ref: '#/definitions/portainer.ResourceControl'
- Title:
- description: Title of the template
- example: Nginx
- type: string
- Type:
- description: Type of created stack (1 - swarm, 2 - compose)
- example: 1
- type: integer
- type: object
- portainer.DockerHub:
- properties:
- Authentication:
- description: Is authentication against DockerHub enabled
- example: true
- type: boolean
- Password:
- description: Password used to authenticate against the DockerHub
- example: passwd
- type: string
- Username:
- description: Username used to authenticate against the DockerHub
- example: user
- type: string
- type: object
- portainer.DockerSnapshot:
- properties:
- DockerSnapshotRaw:
- $ref: '#/definitions/portainer.DockerSnapshotRaw'
- DockerVersion:
- type: string
- GpuUseAll:
- type: boolean
- GpuUseList:
- items:
- type: string
- type: array
- HealthyContainerCount:
- type: integer
- ImageCount:
- type: integer
- RunningContainerCount:
- type: integer
- ServiceCount:
- type: integer
- StackCount:
- type: integer
- StoppedContainerCount:
- type: integer
- Swarm:
- type: boolean
- Time:
- type: integer
- TotalCPU:
- type: integer
- TotalMemory:
- type: integer
- UnhealthyContainerCount:
- type: integer
- VolumeCount:
- type: integer
- type: object
- portainer.DockerSnapshotRaw:
- properties:
- Containers:
- type: object
- Images:
- type: object
- Info:
- type: object
- Networks:
- type: object
- Version:
- type: object
- Volumes:
- type: object
- type: object
- portainer.EdgeGroup:
- properties:
- Dynamic:
- type: boolean
- Endpoints:
- items:
- type: integer
- type: array
- Id:
- description: EdgeGroup Identifier
- example: 1
- type: integer
- Name:
- type: string
- PartialMatch:
- type: boolean
- TagIds:
- items:
- description: Tag identifier
- example: 1
- type: integer
- type: array
- type: object
- portainer.EdgeJob:
- properties:
- Created:
- type: integer
- CronExpression:
- type: string
- Endpoints:
- additionalProperties:
- $ref: '#/definitions/portainer.EdgeJobEndpointMeta'
- type: object
- Id:
- description: EdgeJob Identifier
- example: 1
- type: integer
- Name:
- type: string
- Recurring:
- type: boolean
- ScriptPath:
- type: string
- Version:
- type: integer
- type: object
- portainer.EdgeJobEndpointMeta:
- properties:
- collectLogs:
- type: boolean
- logsStatus:
- type: integer
- type: object
- portainer.EdgeStack:
- properties:
- CreationDate:
- type: integer
- EdgeGroups:
- items:
- description: EdgeGroup Identifier
- example: 1
- type: integer
- type: array
- EntryPoint:
- type: string
- Id:
- description: EdgeStack Identifier
- example: 1
- type: integer
- Name:
- type: string
- ProjectPath:
- type: string
- Prune:
- type: boolean
- Status:
- additionalProperties:
- $ref: '#/definitions/portainer.EdgeStackStatus'
- type: object
- Version:
- type: integer
- type: object
- portainer.EdgeStackStatus:
- properties:
- EndpointID:
- type: integer
- Error:
- type: string
- Type:
- type: integer
- type: object
- portainer.Endpoint:
- properties:
- AuthorizedTeams:
- items:
- type: integer
- type: array
- AuthorizedUsers:
- description: Deprecated in DBVersion == 18
- items:
- description: User identifier who created this template
- example: 3
- type: integer
- type: array
- AzureCredentials:
- $ref: '#/definitions/portainer.AzureCredentials'
- EdgeCheckinInterval:
- description: The check in interval for edge agent (in seconds)
- example: 5
- type: integer
- EdgeID:
- description: The identifier of the edge agent associated with this endpoint
- type: string
- EdgeKey:
- description: The key which is used to map the agent to Portainer
- type: string
- Gpus:
- description: Endpoint Gpus information
- items:
- $ref: '#/definitions/portainer.Pair'
- type: array
- GroupId:
- description: Endpoint group identifier
- example: 1
- type: integer
- Id:
- description: Endpoint Identifier
- example: 1
- type: integer
- Kubernetes:
- $ref: '#/definitions/portainer.KubernetesData'
- description: Associated Kubernetes data
- Name:
- description: Endpoint name
- example: my-endpoint
- type: string
- PublicURL:
- description: URL or IP address where exposed containers will be reachable
- example: docker.mydomain.tld:2375
- type: string
- Snapshots:
- description: List of snapshots
- items:
- $ref: '#/definitions/portainer.DockerSnapshot'
- type: array
- Status:
- description: The status of the endpoint (1 - up, 2 - down)
- example: 1
- type: integer
- TLS:
- description: |-
- Deprecated fields
- Deprecated in DBVersion == 4
- type: boolean
- TLSCACert:
- type: string
- TLSCert:
- type: string
- TLSConfig:
- $ref: '#/definitions/portainer.TLSConfiguration'
- TLSKey:
- type: string
- TagIds:
- description: List of tag identifiers to which this endpoint is associated
- items:
- description: Tag identifier
- example: 1
- type: integer
- type: array
- Tags:
- description: Deprecated in DBVersion == 22
- items:
- type: string
- type: array
- TeamAccessPolicies:
- $ref: '#/definitions/portainer.TeamAccessPolicies'
- description: List of team identifiers authorized to connect to this endpoint
- Type:
- description: Endpoint environment type. 1 for a Docker environment, 2 for
- an agent on Docker environment or 3 for an Azure environment.
- example: 1
- type: integer
- URL:
- description: URL or IP address of the Docker host associated to this endpoint
- example: docker.mydomain.tld:2375
- type: string
- UserAccessPolicies:
- $ref: '#/definitions/portainer.UserAccessPolicies'
- description: List of user identifiers authorized to connect to this endpoint
- type: object
- portainer.EndpointAuthorizations:
- additionalProperties:
- $ref: '#/definitions/portainer.Authorizations'
- type: object
- portainer.EndpointGroup:
- properties:
- AuthorizedTeams:
- items:
- type: integer
- type: array
- AuthorizedUsers:
- description: Deprecated in DBVersion == 18
- items:
- description: User identifier who created this template
- example: 3
- type: integer
- type: array
- Description:
- description: Description associated to the endpoint group
- example: Endpoint group description
- type: string
- Id:
- description: Endpoint group Identifier
- example: 1
- type: integer
- Labels:
- description: Deprecated fields
- items:
- $ref: '#/definitions/portainer.Pair'
- type: array
- Name:
- description: Endpoint group name
- example: my-endpoint-group
- type: string
- TagIds:
- description: List of tags associated to this endpoint group
- items:
- description: Tag identifier
- example: 1
- type: integer
- type: array
- Tags:
- description: Deprecated in DBVersion == 22
- items:
- type: string
- type: array
- TeamAccessPolicies:
- $ref: '#/definitions/portainer.TeamAccessPolicies'
- UserAccessPolicies:
- $ref: '#/definitions/portainer.UserAccessPolicies'
- type: object
- portainer.GitlabRegistryData:
- properties:
- InstanceURL:
- type: string
- ProjectId:
- type: integer
- ProjectPath:
- type: string
- type: object
- portainer.KubernetesConfiguration:
- properties:
- IngressClasses:
- items:
- $ref: '#/definitions/portainer.KubernetesIngressClassConfig'
- type: array
- StorageClasses:
- items:
- $ref: '#/definitions/portainer.KubernetesStorageClassConfig'
- type: array
- UseLoadBalancer:
- type: boolean
- UseServerMetrics:
- type: boolean
- RestrictDefaultNamespace:
- type: boolean
- type: object
- portainer.KubernetesData:
- properties:
- Configuration:
- $ref: '#/definitions/portainer.KubernetesConfiguration'
- Snapshots:
- items:
- $ref: '#/definitions/portainer.KubernetesSnapshot'
- type: array
- type: object
- portainer.KubernetesIngressClassConfig:
- properties:
- Name:
- type: string
- Type:
- type: string
- type: object
- portainer.KubernetesSnapshot:
- properties:
- KubernetesVersion:
- type: string
- NodeCount:
- type: integer
- Time:
- type: integer
- TotalCPU:
- type: integer
- TotalMemory:
- type: integer
- type: object
- portainer.KubernetesStorageClassConfig:
- properties:
- AccessModes:
- items:
- type: string
- type: array
- AllowVolumeExpansion:
- type: boolean
- Name:
- type: string
- Provisioner:
- type: string
- type: object
- portainer.InternalAuthSettings:
- properties:
- RequiredPasswordLength:
- description: The minimum character length a user can set their password
- example: 12
- type: integer
- portainer.LDAPGroupSearchSettings:
- properties:
- GroupAttribute:
- description: LDAP attribute which denotes the group membership
- example: member
- type: string
- GroupBaseDN:
- description: The distinguished name of the element from which the LDAP server
- will search for groups
- example: dc=ldap,dc=domain,dc=tld
- type: string
- GroupFilter:
- description: The LDAP search filter used to select group elements, optional
- example: (objectClass=account
- type: string
- type: object
- portainer.LDAPSearchSettings:
- properties:
- BaseDN:
- description: The distinguished name of the element from which the LDAP server
- will search for users
- example: dc=ldap,dc=domain,dc=tld
- type: string
- Filter:
- description: Optional LDAP search filter used to select user elements
- example: (objectClass=account)
- type: string
- UserNameAttribute:
- description: LDAP attribute which denotes the username
- example: uid
- type: string
- type: object
- portainer.LDAPSettings:
- properties:
- AnonymousMode:
- description: Enable this option if the server is configured for Anonymous
- access. When enabled, ReaderDN and Password will not be used
- example: true
- type: boolean
- AutoCreateUsers:
- description: Automatically provision users and assign them to matching LDAP
- group names
- example: true
- type: boolean
- GroupSearchSettings:
- items:
- $ref: '#/definitions/portainer.LDAPGroupSearchSettings'
- type: array
- Password:
- description: Password of the account that will be used to search users
- example: readonly-password
- type: string
- ReaderDN:
- description: Account that will be used to search for users
- example: cn=readonly-account,dc=ldap,dc=domain,dc=tld
- type: string
- SearchSettings:
- items:
- $ref: '#/definitions/portainer.LDAPSearchSettings'
- type: array
- StartTLS:
- description: Whether LDAP connection should use StartTLS
- example: true
- type: boolean
- TLSConfig:
- $ref: '#/definitions/portainer.TLSConfiguration'
- URL:
- description: URL or IP address of the LDAP server
- example: myldap.domain.tld:389
- type: string
- type: object
- portainer.OAuthSettings:
- properties:
- AccessTokenURI:
- type: string
- AuthorizationURI:
- type: string
- ClientID:
- type: string
- ClientSecret:
- type: string
- DefaultTeamID:
- type: integer
- OAuthAutoCreateUsers:
- type: boolean
- RedirectURI:
- type: string
- ResourceURI:
- type: string
- Scopes:
- type: string
- UserIdentifier:
- type: string
- type: object
- portainer.Pair:
- properties:
- name:
- example: name
- type: string
- value:
- example: value
- type: string
- type: object
- portainer.Registry:
- properties:
- Authentication:
- description: Is authentication against this registry enabled
- example: true
- type: boolean
- AuthorizedTeams:
- items:
- type: integer
- type: array
- AuthorizedUsers:
- description: |-
- Deprecated fields
- Deprecated in DBVersion == 18
- items:
- description: User identifier who created this template
- example: 3
- type: integer
- type: array
- Gitlab:
- $ref: '#/definitions/portainer.GitlabRegistryData'
- Id:
- description: Registry Identifier
- example: 1
- type: integer
- ManagementConfiguration:
- $ref: '#/definitions/portainer.RegistryManagementConfiguration'
- Name:
- description: Registry Name
- example: my-registry
- type: string
- Password:
- description: Password used to authenticate against this registry
- example: registry_password
- type: string
- TeamAccessPolicies:
- $ref: '#/definitions/portainer.TeamAccessPolicies'
- Type:
- description: Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet)
- enum:
- - 1
- - 2
- - 3
- - 4
- - 5
- type: integer
- URL:
- description: URL or IP address of the Docker registry
- example: registry.mydomain.tld:2375
- type: string
- BaseURL:
- description: Base URL or IP address of the ProGet registry
- example: registry.mydomain.tld:2375
- type: string
- UserAccessPolicies:
- $ref: '#/definitions/portainer.UserAccessPolicies'
- Username:
- description: Username used to authenticate against this registry
- example: registry user
- type: string
- type: object
- portainer.RegistryManagementConfiguration:
- properties:
- Authentication:
- type: boolean
- Password:
- type: string
- TLSConfig:
- $ref: '#/definitions/portainer.TLSConfiguration'
- Type:
- type: integer
- Username:
- type: string
- type: object
- portainer.ResourceControl:
- properties:
- AccessLevel:
- type: integer
- AdministratorsOnly:
- description: Permit access to resource only to admins
- example: true
- type: boolean
- Id:
- description: ResourceControl Identifier
- example: 1
- type: integer
- OwnerId:
- description: |-
- Deprecated fields
- Deprecated in DBVersion == 2
- type: integer
- Public:
- description: Permit access to the associated resource to any user
- example: true
- type: boolean
- ResourceId:
- description: |-
- Docker resource identifier on which access control will be applied.\
- In the case of a resource control applied to a stack, use the stack name as identifier
- example: 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08
- type: string
- SubResourceIds:
- description: List of Docker resources that will inherit this access control
- example:
- - 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08
- items:
- type: string
- type: array
- System:
- type: boolean
- TeamAccesses:
- items:
- $ref: '#/definitions/portainer.TeamResourceAccess'
- type: array
- Type:
- description: |-
- Type of Docker resource. Valid values are: 1- container, 2 -service
- 3 - volume, 4 - secret, 5 - stack, 6 - config or 7 - custom template
- example: 1
- type: integer
- UserAccesses:
- items:
- $ref: '#/definitions/portainer.UserResourceAccess'
- type: array
- type: object
- portainer.Role:
- properties:
- Authorizations:
- $ref: '#/definitions/portainer.Authorizations'
- description: Authorizations associated to a role
- Description:
- description: Role description
- example: Read-only access of all resources in an endpoint
- type: string
- Id:
- description: Role Identifier
- example: 1
- type: integer
- Name:
- description: Role name
- example: HelpDesk
- type: string
- Priority:
- type: integer
- type: object
- portainer.Settings:
- properties:
- AllowBindMountsForRegularUsers:
- description: Whether non-administrator should be able to use bind mounts when
- creating containers
- example: false
- type: boolean
- AllowContainerCapabilitiesForRegularUsers:
- description: Whether non-administrator should be able to use container capabilities
- type: boolean
- AllowDeviceMappingForRegularUsers:
- description: Whether non-administrator should be able to use device mapping
- type: boolean
- AllowHostNamespaceForRegularUsers:
- description: Whether non-administrator should be able to use the host pid
- type: boolean
- AllowPrivilegedModeForRegularUsers:
- description: Whether non-administrator should be able to use privileged mode
- when creating containers
- example: false
- type: boolean
- AllowStackManagementForRegularUsers:
- description: Whether non-administrator should be able to manage stacks
- type: boolean
- AllowVolumeBrowserForRegularUsers:
- description: Whether non-administrator should be able to browse volumes
- type: boolean
- AuthenticationMethod:
- description: 'Active authentication method for the Portainer instance. Valid
- values are: 1 for internal, 2 for LDAP, or 3 for oauth'
- example: 1
- type: integer
- BlackListedLabels:
- description: A list of label name & value that will be used to hide containers
- when querying containers
- items:
- $ref: '#/definitions/portainer.Pair'
- type: array
- EdgeAgentCheckinInterval:
- description: The default check in interval for edge agent (in seconds)
- example: 5
- type: integer
- EnableEdgeComputeFeatures:
- description: Whether edge compute features are enabled
- type: boolean
- EnableHostManagementFeatures:
- description: Whether host management features are enabled
- type: boolean
- EnableTelemetry:
- description: Whether telemetry is enabled
- example: false
- type: boolean
- InternalAuthSettings:
- $ref: '#/definitions/portainer.InternalAuthSettings'
- LDAPSettings:
- $ref: '#/definitions/portainer.LDAPSettings'
- LogoURL:
- description: URL to a logo that will be displayed on the login page as well
- as on top of the sidebar. Will use default Portainer logo when value is
- empty string
- example: https://mycompany.mydomain.tld/logo.png
- type: string
- OAuthSettings:
- $ref: '#/definitions/portainer.OAuthSettings'
- SnapshotInterval:
- description: The interval in which endpoint snapshots are created
- example: 5m
- type: string
- TemplatesURL:
- description: URL to the templates that will be displayed in the UI when navigating
- to App Templates
- example: https://raw.githubusercontent.com/portainer/templates/master/templates.json
- type: string
- UserSessionTimeout:
- description: The duration of a user session
- example: 5m
- type: string
- displayDonationHeader:
- description: Deprecated fields
- type: boolean
- displayExternalContributors:
- type: boolean
- type: object
- portainer.Stack:
- properties:
- EndpointId:
- description: Endpoint identifier. Reference the endpoint that will be used
- for deployment
- example: 1
- type: integer
- EntryPoint:
- description: Path to the Stack file
- example: docker-compose.yml
- type: string
- Env:
- description: A list of environment variables used during stack deployment
- items:
- $ref: '#/definitions/portainer.Pair'
- type: array
- Id:
- description: Stack Identifier
- example: 1
- type: integer
- Name:
- description: Stack name
- example: myStack
- type: string
- ResourceControl:
- $ref: '#/definitions/portainer.ResourceControl'
- Status:
- description: Stack status (1 - active, 2 - inactive)
- example: 1
- type: integer
- SwarmId:
- description: Cluster identifier of the Swarm cluster where the stack is deployed
- example: jpofkc0i9uo9wtx1zesuk649w
- type: string
- Type:
- description: Stack type. 1 for a Swarm stack, 2 for a Compose stack
- example: 2
- type: integer
- createdBy:
- description: The username which created this stack
- example: admin
- type: string
- creationDate:
- description: The date in unix time when stack was created
- example: 1587399600
- type: integer
- projectPath:
- description: Path on disk to the repository hosting the Stack file
- example: /data/compose/myStack_jpofkc0i9uo9wtx1zesuk649w
- type: string
- updateDate:
- description: The date in unix time when stack was last updated
- example: 1587399600
- type: integer
- updatedBy:
- description: The username which last updated this stack
- example: bob
- type: string
- type: object
- portainer.Status:
- properties:
- Version:
- description: Portainer API version
- example: 2.0.0
- type: string
- type: object
- portainer.TLSConfiguration:
- properties:
- TLS:
- description: Use TLS
- example: true
- type: boolean
- TLSCACert:
- description: Path to the TLS CA certificate file
- example: /data/tls/ca.pem
- type: string
- TLSCert:
- description: Path to the TLS client certificate file
- example: /data/tls/cert.pem
- type: string
- TLSKey:
- description: Path to the TLS client key file
- example: /data/tls/key.pem
- type: string
- TLSSkipVerify:
- description: Skip the verification of the server TLS certificate
- example: false
- type: boolean
- type: object
- portainer.Tag:
- properties:
- EndpointGroups:
- additionalProperties:
- type: boolean
- description: A set of endpoint group ids that have this tag
- type: object
- Endpoints:
- additionalProperties:
- type: boolean
- description: A set of endpoint ids that have this tag
- type: object
- Name:
- description: Tag name
- example: org/acme
- type: string
- id:
- description: Tag identifier
- example: 1
- type: integer
- type: object
- portainer.Team:
- properties:
- Id:
- description: Team Identifier
- example: 1
- type: integer
- Name:
- description: Team name
- example: developers
- type: string
- type: object
- portainer.TeamAccessPolicies:
- additionalProperties:
- $ref: '#/definitions/portainer.AccessPolicy'
- type: object
- portainer.TeamMembership:
- properties:
- Id:
- description: Membership Identifier
- example: 1
- type: integer
- Role:
- description: Team role (1 for team leader and 2 for team member)
- example: 1
- type: integer
- TeamID:
- description: Team identifier
- example: 1
- type: integer
- UserID:
- description: User identifier
- example: 1
- type: integer
- type: object
- portainer.TeamResourceAccess:
- properties:
- AccessLevel:
- type: integer
- TeamId:
- type: integer
- type: object
- portainer.Template:
- properties:
- Id:
- description: |-
- Mandatory container/stack fields
- Template Identifier
- example: 1
- type: integer
- administrator_only:
- description: Whether the template should be available to administrators only
- example: true
- type: boolean
- categories:
- description: A list of categories associated to the template
- example:
- - database
- items:
- type: string
- type: array
- command:
- description: The command that will be executed in a container template
- example: ls -lah
- type: string
- description:
- description: Description of the template
- example: High performance web server
- type: string
- env:
- description: A list of environment variables used during the template deployment
- items:
- $ref: '#/definitions/portainer.TemplateEnv'
- type: array
- hostname:
- description: Container hostname
- example: mycontainer
- type: string
- image:
- description: |-
- Mandatory container fields
- Image associated to a container template. Mandatory for a container template
- example: nginx:latest
- type: string
- interactive:
- description: |-
- Whether the container should be started in
- interactive mode (-i -t equivalent on the CLI)
- example: true
- type: boolean
- labels:
- description: Container labels
- items:
- $ref: '#/definitions/portainer.Pair'
- type: array
- logo:
- description: URL of the template's logo
- example: https://cloudinovasi.id/assets/img/logos/nginx.png
- type: string
- name:
- description: |-
- Optional stack/container fields
- Default name for the stack/container to be used on deployment
- example: mystackname
- type: string
- network:
- description: Name of a network that will be used on container deployment if
- it exists inside the environment
- example: mynet
- type: string
- note:
- description: A note that will be displayed in the UI. Supports HTML content
- example: This is my custom template
- type: string
- platform:
- description: |-
- Platform associated to the template.
- Valid values are: 'linux', 'windows' or leave empty for multi-platform
- example: linux
- type: string
- ports:
- description: A list of ports exposed by the container
- example:
- - 8080:80/tcp
- items:
- type: string
- type: array
- privileged:
- description: Whether the container should be started in privileged mode
- example: true
- type: boolean
- registry:
- description: |-
- Optional container fields
- The URL of a registry associated to the image for a container template
- example: quay.io
- type: string
- repository:
- $ref: '#/definitions/portainer.TemplateRepository'
- description: Mandatory stack fields
- restart_policy:
- description: Container restart policy
- example: on-failure
- type: string
- stackFile:
- description: |-
- Mandatory Edge stack fields
- Stack file used for this template
- type: string
- title:
- description: Title of the template
- example: Nginx
- type: string
- type:
- description: 'Template type. Valid values are: 1 (container), 2 (Swarm stack)
- or 3 (Compose stack)'
- example: 1
- type: integer
- volumes:
- description: A list of volumes used during the container template deployment
- items:
- $ref: '#/definitions/portainer.TemplateVolume'
- type: array
- type: object
- portainer.TemplateEnv:
- properties:
- default:
- description: Default value that will be set for the variable
- example: default_value
- type: string
- description:
- description: Content of the tooltip that will be generated in the UI
- example: MySQL root account password
- type: string
- label:
- description: Text for the label that will be generated in the UI
- example: Root password
- type: string
- name:
- description: name of the environment variable
- example: MYSQL_ROOT_PASSWORD
- type: string
- preset:
- description: If set to true, will not generate any input for this variable
- in the UI
- example: false
- type: boolean
- select:
- description: A list of name/value that will be used to generate a dropdown
- in the UI
- items:
- $ref: '#/definitions/portainer.TemplateEnvSelect'
- type: array
- type: object
- portainer.TemplateEnvSelect:
- properties:
- default:
- description: Will set this choice as the default choice
- example: false
- type: boolean
- text:
- description: Some text that will displayed as a choice
- example: text value
- type: string
- value:
- description: A value that will be associated to the choice
- example: value
- type: string
- type: object
- portainer.TemplateRepository:
- properties:
- stackfile:
- description: Path to the stack file inside the git repository
- example: ./subfolder/docker-compose.yml
- type: string
- url:
- description: URL of a git repository used to deploy a stack template. Mandatory
- for a Swarm/Compose stack template
- example: https://github.com/portainer/portainer-compose
- type: string
- type: object
- portainer.TemplateVolume:
- properties:
- bind:
- description: Path on the host
- example: /tmp
- type: string
- container:
- description: Path inside the container
- example: /data
- type: string
- readonly:
- description: Whether the volume used should be readonly
- example: true
- type: boolean
- type: object
- portainer.User:
- properties:
- EndpointAuthorizations:
- $ref: '#/definitions/portainer.EndpointAuthorizations'
- Id:
- description: User Identifier
- example: 1
- type: integer
- Password:
- example: passwd
- type: string
- PortainerAuthorizations:
- $ref: '#/definitions/portainer.Authorizations'
- description: |-
- Deprecated fields
- Deprecated in DBVersion == 25
- Role:
- description: User role (1 for administrator account and 2 for regular account)
- example: 1
- type: integer
- Username:
- example: bob
- type: string
- type: object
- portainer.UserAccessPolicies:
- additionalProperties:
- $ref: '#/definitions/portainer.AccessPolicy'
- type: object
- portainer.UserResourceAccess:
- properties:
- AccessLevel:
- type: integer
- UserId:
- type: integer
- type: object
- portainer.Webhook:
- properties:
- EndpointId:
- type: integer
- Id:
- description: Webhook Identifier
- example: 1
- type: integer
- ResourceId:
- type: string
- Token:
- type: string
- Type:
- type: integer
- type: object
- registries.registryConfigurePayload:
- properties:
- authentication:
- description: Is authentication against this registry enabled
- example: false
- type: boolean
- password:
- description: Password used to authenticate against this registry. required
- when Authentication is true
- example: registry_password
- type: string
- tls:
- description: Use TLS
- example: true
- type: boolean
- tlscacertFile:
- description: The TLS CA certificate file
- items:
- type: integer
- type: array
- tlscertFile:
- description: The TLS client certificate file
- items:
- type: integer
- type: array
- tlskeyFile:
- description: The TLS client key file
- items:
- type: integer
- type: array
- tlsskipVerify:
- description: Skip the verification of the server TLS certificate
- example: false
- type: boolean
- username:
- description: Username used to authenticate against this registry. Required
- when Authentication is true
- example: registry_user
- type: string
- required:
- - authentication
- type: object
- registries.registryCreatePayload:
- properties:
- authentication:
- description: Is authentication against this registry enabled
- example: false
- type: boolean
- gitlab:
- $ref: '#/definitions/portainer.GitlabRegistryData'
- description: Gitlab specific details, required when type = 4
- name:
- description: Name that will be used to identify this registry
- example: my-registry
- type: string
- password:
- description: Password used to authenticate against this registry. required
- when Authentication is true
- example: registry_password
- type: string
- type:
- description: 'Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container
- registry), 3 (custom registry), 4 (Gitlab registry) or 5 (ProGet registry)'
- enum:
- - 1
- - 2
- - 3
- - 4
- - 5
- example: 1
- type: integer
- url:
- description: URL or IP address of the Docker registry
- example: registry.mydomain.tld:2375
- type: string
- baseUrl:
- description: Base URL or IP address of the ProGet registry
- example: registry.mydomain.tld:2375
- type: string
- username:
- description: Username used to authenticate against this registry. Required
- when Authentication is true
- example: registry_user
- type: string
- required:
- - authentication
- - name
- - type
- - url
- type: object
- registries.registryUpdatePayload:
- properties:
- authentication:
- description: Is authentication against this registry enabled
- example: false
- type: boolean
- name:
- description: Name that will be used to identify this registry
- example: my-registry
- type: string
- password:
- description: Password used to authenticate against this registry. required
- when Authentication is true
- example: registry_password
- type: string
- teamAccessPolicies:
- $ref: '#/definitions/portainer.TeamAccessPolicies'
- url:
- description: URL or IP address of the Docker registry
- example: registry.mydomain.tld:2375
- type: string
- baseUrl:
- description: Base URL or IP address of the ProGet registry
- example: registry.mydomain.tld:2375
- type: string
- userAccessPolicies:
- $ref: '#/definitions/portainer.UserAccessPolicies'
- username:
- description: Username used to authenticate against this registry. Required
- when Authentication is true
- example: registry_user
- type: string
- required:
- - authentication
- - name
- - url
- type: object
- resourcecontrols.resourceControlCreatePayload:
- properties:
- administratorsOnly:
- description: Permit access to resource only to admins
- example: true
- type: boolean
- public:
- description: Permit access to the associated resource to any user
- example: true
- type: boolean
- resourceID:
- example: 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08
- type: string
- subResourceIDs:
- description: List of Docker resources that will inherit this access control
- example:
- - 617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08
- items:
- type: string
- type: array
- teams:
- description: List of team identifiers with access to the associated resource
- example:
- - 56
- - 7
- items:
- type: integer
- type: array
- type:
- description: |-
- Type of Docker resource. Valid values are: container, volume\
- service, secret, config or stack
- example: container
- type: string
- users:
- description: List of user identifiers with access to the associated resource
- example:
- - 1
- - 4
- items:
- type: integer
- type: array
- required:
- - resourceID
- - type
- type: object
- resourcecontrols.resourceControlUpdatePayload:
- properties:
- administratorsOnly:
- description: Permit access to resource only to admins
- example: true
- type: boolean
- public:
- description: Permit access to the associated resource to any user
- example: true
- type: boolean
- teams:
- description: List of team identifiers with access to the associated resource
- example:
- - 7
- items:
- type: integer
- type: array
- users:
- description: List of user identifiers with access to the associated resource
- example:
- - 4
- items:
- type: integer
- type: array
- type: object
- settings.publicSettingsResponse:
- properties:
- AllowBindMountsForRegularUsers:
- description: Whether non-administrator should be able to use bind mounts when
- creating containers
- example: true
- type: boolean
- AllowContainerCapabilitiesForRegularUsers:
- description: Whether non-administrator should be able to use container capabilities
- example: true
- type: boolean
- AllowDeviceMappingForRegularUsers:
- description: Whether non-administrator should be able to use device mapping
- example: true
- type: boolean
- AllowHostNamespaceForRegularUsers:
- description: Whether non-administrator should be able to use the host pid
- example: true
- type: boolean
- AllowPrivilegedModeForRegularUsers:
- description: Whether non-administrator should be able to use privileged mode
- when creating containers
- example: true
- type: boolean
- AllowStackManagementForRegularUsers:
- description: Whether non-administrator should be able to manage stacks
- example: true
- type: boolean
- AllowVolumeBrowserForRegularUsers:
- description: Whether non-administrator should be able to browse volumes
- example: true
- type: boolean
- AuthenticationMethod:
- description: 'Active authentication method for the Portainer instance. Valid
- values are: 1 for internal, 2 for LDAP, or 3 for oauth'
- example: 1
- type: integer
- EnableEdgeComputeFeatures:
- description: Whether edge compute features are enabled
- example: true
- type: boolean
- EnableHostManagementFeatures:
- description: Whether host management features are enabled
- example: true
- type: boolean
- EnableTelemetry:
- description: Whether telemetry is enabled
- example: true
- type: boolean
- LogoURL:
- description: URL to a logo that will be displayed on the login page as well
- as on top of the sidebar. Will use default Portainer logo when value is
- empty string
- example: https://mycompany.mydomain.tld/logo.png
- type: string
- OAuthLoginURI:
- description: The URL used for oauth login
- example: https://gitlab.com/oauth
- type: string
- type: object
- settings.settingsLDAPCheckPayload:
- properties:
- ldapsettings:
- $ref: '#/definitions/portainer.LDAPSettings'
- type: object
- settings.settingsUpdatePayload:
- properties:
- allowBindMountsForRegularUsers:
- description: Whether non-administrator should be able to use bind mounts when
- creating containers
- example: false
- type: boolean
- allowContainerCapabilitiesForRegularUsers:
- description: Whether non-administrator should be able to use container capabilities
- example: true
- type: boolean
- allowDeviceMappingForRegularUsers:
- description: Whether non-administrator should be able to use device mapping
- example: true
- type: boolean
- allowHostNamespaceForRegularUsers:
- description: Whether non-administrator should be able to use the host pid
- example: true
- type: boolean
- allowPrivilegedModeForRegularUsers:
- description: Whether non-administrator should be able to use privileged mode
- when creating containers
- example: false
- type: boolean
- allowStackManagementForRegularUsers:
- description: Whether non-administrator should be able to manage stacks
- example: true
- type: boolean
- allowVolumeBrowserForRegularUsers:
- description: Whether non-administrator should be able to browse volumes
- example: true
- type: boolean
- authenticationMethod:
- description: 'Active authentication method for the Portainer instance. Valid
- values are: 1 for internal, 2 for LDAP, or 3 for oauth'
- example: 1
- type: integer
- blackListedLabels:
- description: A list of label name & value that will be used to hide containers
- when querying containers
- items:
- $ref: '#/definitions/portainer.Pair'
- type: array
- edgeAgentCheckinInterval:
- description: The default check in interval for edge agent (in seconds)
- example: 5
- type: integer
- enableEdgeComputeFeatures:
- description: Whether edge compute features are enabled
- example: true
- type: boolean
- enableHostManagementFeatures:
- description: Whether host management features are enabled
- example: true
- type: boolean
- enableTelemetry:
- description: Whether telemetry is enabled
- example: false
- type: boolean
- internalAuthSettings:
- $ref: '#/definitions/portainer.InternalAuthSettings'
- ldapsettings:
- $ref: '#/definitions/portainer.LDAPSettings'
- logoURL:
- description: URL to a logo that will be displayed on the login page as well
- as on top of the sidebar. Will use default Portainer logo when value is
- empty string
- example: https://mycompany.mydomain.tld/logo.png
- type: string
- oauthSettings:
- $ref: '#/definitions/portainer.OAuthSettings'
- snapshotInterval:
- description: The interval in which endpoint snapshots are created
- example: 5m
- type: string
- templatesURL:
- description: URL to the templates that will be displayed in the UI when navigating
- to App Templates
- example: https://raw.githubusercontent.com/portainer/templates/master/templates.json
- type: string
- userSessionTimeout:
- description: The duration of a user session
- example: 5m
- type: string
- type: object
- stacks.composeStackFromFileContentPayload:
- properties:
- env:
- description: A list of environment variables used during stack deployment
- items:
- $ref: '#/definitions/portainer.Pair'
- type: array
- name:
- description: Name of the stack
- example: myStack
- type: string
- stackFileContent:
- description: Content of the Stack file
- example: |-
- version: 3
- services:
- web:
- image:nginx
- type: string
- required:
- - name
- - stackFileContent
- type: object
- stacks.composeStackFromGitRepositoryPayload:
- properties:
- composeFilePathInRepository:
- default: docker-compose.yml
- description: Path to the Stack file inside the Git repository
- example: docker-compose.yml
- type: string
- env:
- description: A list of environment variables used during stack deployment
- items:
- $ref: '#/definitions/portainer.Pair'
- type: array
- name:
- description: Name of the stack
- example: myStack
- type: string
- repositoryAuthentication:
- description: Use basic authentication to clone the Git repository
- example: true
- type: boolean
- repositoryPassword:
- description: Password used in basic authentication. Required when RepositoryAuthentication
- is true.
- example: myGitPassword
- type: string
- repositoryReferenceName:
- description: Reference name of a Git repository hosting the Stack file
- example: refs/heads/master
- type: string
- repositoryURL:
- description: URL of a Git repository hosting the Stack file
- example: https://github.com/openfaas/faas
- type: string
- repositoryUsername:
- description: Username used in basic authentication. Required when RepositoryAuthentication
- is true.
- example: myGitUsername
- type: string
- required:
- - name
- - repositoryURL
- type: object
- stacks.stackFileResponse:
- properties:
- StackFileContent:
- description: Content of the Stack file
- example: |-
- version: 3
- services:
- web:
- image:nginx
- type: string
- type: object
- stacks.stackMigratePayload:
- properties:
- endpointID:
- description: Endpoint identifier of the target endpoint where the stack will
- be relocated
- example: 2
- type: integer
- name:
- description: If provided will rename the migrated stack
- example: new-stack
- type: string
- swarmID:
- description: Swarm cluster identifier, must match the identifier of the cluster
- where the stack will be relocated
- example: jpofkc0i9uo9wtx1zesuk649w
- type: string
- required:
- - endpointID
- type: object
- stacks.swarmStackFromFileContentPayload:
- properties:
- env:
- description: A list of environment variables used during stack deployment
- items:
- $ref: '#/definitions/portainer.Pair'
- type: array
- name:
- description: Name of the stack
- example: myStack
- type: string
- stackFileContent:
- description: Content of the Stack file
- example: |-
- version: 3
- services:
- web:
- image:nginx
- type: string
- swarmID:
- description: Swarm cluster identifier
- example: jpofkc0i9uo9wtx1zesuk649w
- type: string
- required:
- - name
- - stackFileContent
- - swarmID
- type: object
- stacks.swarmStackFromGitRepositoryPayload:
- properties:
- composeFilePathInRepository:
- default: docker-compose.yml
- description: Path to the Stack file inside the Git repository
- example: docker-compose.yml
- type: string
- env:
- description: A list of environment variables used during stack deployment
- items:
- $ref: '#/definitions/portainer.Pair'
- type: array
- name:
- description: Name of the stack
- example: myStack
- type: string
- repositoryAuthentication:
- description: Use basic authentication to clone the Git repository
- example: true
- type: boolean
- repositoryPassword:
- description: Password used in basic authentication. Required when RepositoryAuthentication
- is true.
- example: myGitPassword
- type: string
- repositoryReferenceName:
- description: Reference name of a Git repository hosting the Stack file
- example: refs/heads/master
- type: string
- repositoryURL:
- description: URL of a Git repository hosting the Stack file
- example: https://github.com/openfaas/faas
- type: string
- repositoryUsername:
- description: Username used in basic authentication. Required when RepositoryAuthentication
- is true.
- example: myGitUsername
- type: string
- swarmID:
- description: Swarm cluster identifier
- example: jpofkc0i9uo9wtx1zesuk649w
- type: string
- required:
- - name
- - repositoryURL
- - swarmID
- type: object
- stacks.updateSwarmStackPayload:
- properties:
- env:
- description: A list of environment variables used during stack deployment
- items:
- $ref: '#/definitions/portainer.Pair'
- type: array
- prune:
- description: Prune services that are no longer referenced (only available
- for Swarm stacks)
- example: true
- type: boolean
- stackFileContent:
- description: New content of the Stack file
- example: |-
- version: 3
- services:
- web:
- image:nginx
- type: string
- type: object
- status.inspectVersionResponse:
- properties:
- LatestVersion:
- description: The latest version available
- example: 2.0.0
- type: string
- UpdateAvailable:
- description: Whether portainer has an update available
- example: false
- type: boolean
- type: object
- tags.tagCreatePayload:
- properties:
- name:
- description: Name
- example: org/acme
- type: string
- required:
- - name
- type: object
- teammemberships.teamMembershipCreatePayload:
- properties:
- role:
- description: Role for the user inside the team (1 for leader and 2 for regular
- member)
- enum:
- - 1
- - 2
- example: 1
- type: integer
- teamID:
- description: Team identifier
- example: 1
- type: integer
- userID:
- description: User identifier
- example: 1
- type: integer
- required:
- - role
- - teamID
- - userID
- type: object
- teammemberships.teamMembershipUpdatePayload:
- properties:
- role:
- description: Role for the user inside the team (1 for leader and 2 for regular
- member)
- enum:
- - 1
- - 2
- example: 1
- type: integer
- teamID:
- description: Team identifier
- example: 1
- type: integer
- userID:
- description: User identifier
- example: 1
- type: integer
- required:
- - role
- - teamID
- - userID
- type: object
- teams.teamCreatePayload:
- properties:
- name:
- description: Name
- example: developers
- type: string
- required:
- - name
- type: object
- teams.teamUpdatePayload:
- properties:
- name:
- description: Name
- example: developers
- type: string
- type: object
- templates.filePayload:
- properties:
- composeFilePathInRepository:
- description: Path to the file inside the git repository
- example: ./subfolder/docker-compose.yml
- type: string
- repositoryURL:
- description: URL of a git repository where the file is stored
- example: https://github.com/portainer/portainer-compose
- type: string
- required:
- - composeFilePathInRepository
- - repositoryURL
- type: object
- templates.fileResponse:
- properties:
- fileContent:
- description: The requested file content
- type: string
- type: object
- templates.listResponse:
- properties:
- templates:
- items:
- $ref: '#/definitions/portainer.Template'
- type: array
- version:
- type: string
- type: object
- users.adminInitPayload:
- properties:
- password:
- description: Password for the admin user
- example: admin-password
- type: string
- username:
- description: Username for the admin user
- example: admin
- type: string
- required:
- - password
- - username
- type: object
- users.userCreatePayload:
- properties:
- password:
- example: cg9Wgky3
- type: string
- role:
- description: User role (1 for administrator account and 2 for regular account)
- enum:
- - 1
- - 2
- example: 2
- type: integer
- username:
- example: bob
- type: string
- required:
- - password
- - role
- - username
- type: object
- users.userUpdatePasswordPayload:
- properties:
- newPassword:
- description: New Password
- example: new_passwd
- type: string
- password:
- description: Current Password
- example: passwd
- type: string
- required:
- - newPassword
- - password
- type: object
- users.userUpdatePayload:
- properties:
- password:
- example: cg9Wgky3
- type: string
- role:
- description: User role (1 for administrator account and 2 for regular account)
- enum:
- - 1
- - 2
- example: 2
- type: integer
- username:
- example: bob
- type: string
- required:
- - password
- - role
- - username
- type: object
- webhooks.webhookCreatePayload:
- properties:
- endpointID:
- type: integer
- resourceID:
- type: string
- webhookType:
- type: integer
- type: object
-info:
- contact:
- email: info@portainer.io
- description: |
- Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI and everything you can do with the UI can be done using the HTTP API.
- Examples are available at https://documentation.portainer.io/api/api-examples/
- You can find out more about Portainer at [http://portainer.io](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/).
-
- # Authentication
-
- Most of the API endpoints require to be authenticated as well as some level of authorization to be used.
- Portainer API uses JSON Web Token to manage authentication and thus requires you to provide a token in the **Authorization** header of each request
- with the **Bearer** authentication mechanism.
-
- Example:
-
- ```
- Bearer abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890abcdefghijklmnopqrstuvwxyzAB
- ```
-
- # Security
-
- Each API endpoint has an associated access policy, it is documented in the description of each endpoint.
-
- Different access policies are available:
-
- - Public access
- - Authenticated access
- - Restricted access
- - Administrator access
-
- ### Public access
-
- No authentication is required to access the endpoints with this access policy.
-
- ### Authenticated access
-
- Authentication is required to access the endpoints with this access policy.
-
- ### Restricted access
-
- Authentication is required to access the endpoints with this access policy.
- Extra-checks might be added to ensure access to the resource is granted. Returned data might also be filtered.
-
- ### Administrator access
-
- Authentication as well as an administrator role are required to access the endpoints with this access policy.
-
- # Execute Docker requests
-
- Portainer **DO NOT** expose specific endpoints to manage your Docker resources (create a container, remove a volume, etc...).
-
- Instead, it acts as a reverse-proxy to the Docker HTTP API. This means that you can execute Docker requests **via** the Portainer HTTP API.
-
- To do so, you can use the `/endpoints/{id}/docker` Portainer API endpoint (which is not documented below due to Swagger limitations). This endpoint has a restricted access policy so you still need to be authenticated to be able to query this endpoint. Any query on this endpoint will be proxied to the Docker API of the associated endpoint (requests and responses objects are the same as documented in the Docker API).
-
- **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://documentation.portainer.io/api/api-examples/).
- license: {}
- title: PortainerCE API
- version: 2.0.0
-paths:
- /auth:
- post:
- consumes:
- - application/json
- description: Use this endpoint to authenticate against Portainer using a username
- and password.
- operationId: AuthenticateUser
- parameters:
- - description: Credentials used for authentication
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/auth.authenticatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/auth.authenticateResponse'
- '400':
- description: Invalid request
- '422':
- description: Invalid Credentials
- '500':
- description: Server error
- summary: Authenticate
- tags:
- - auth
- /auth/logout:
- post:
- consumes:
- - application/json
- operationId: logout
- produces:
- - application/json
- responses:
- '204':
- description: ''
- security:
- - jwt: []
- summary: Logout
- tags:
- - auth
- /auth/oauth/validate:
- post:
- consumes:
- - application/json
- operationId: authenticate_oauth
- parameters:
- - description: OAuth Credentials used for authentication
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/auth.oauthPayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/auth.authenticateResponse'
- '400':
- description: Invalid request
- '422':
- description: Invalid Credentials
- '500':
- description: Server error
- summary: Authenticate with OAuth
- tags:
- - auth
- /custom_templates:
- get:
- description: |-
- List available custom templates.
- **Access policy**: authenticated
- operationId: CustomTemplateList
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- items:
- $ref: '#/definitions/portainer.CustomTemplate'
- type: array
- '500':
- description: Server error
- security:
- - jwt: []
- summary: List available custom templates
- tags:
- - custom_templates
- post:
- consumes:
- - application/json
- - ' multipart/form-data'
- description: |-
- Create a custom template.
- **Access policy**: authenticated
- operationId: CustomTemplateCreate
- parameters:
- - description: method for creating template
- enum:
- - string
- - file
- - repository
- in: query
- name: method
- required: true
- type: string
- - description: Required when using method=string
- in: body
- name: body_string
- schema:
- $ref: '#/definitions/customtemplates.customTemplateFromFileContentPayload'
- - description: Required when using method=repository
- in: body
- name: body_repository
- schema:
- $ref: '#/definitions/customtemplates.customTemplateFromGitRepositoryPayload'
- - description: Title of the template. required when method is file
- in: formData
- name: Title
- type: string
- - description: Description of the template. required when method is file
- in: formData
- name: Description
- type: string
- - description: A note that will be displayed in the UI. Supports HTML content
- in: formData
- name: Note
- type: string
- - description: Platform associated to the template (1 - 'linux', 2 - 'windows').
- required when method is file
- enum:
- - 1
- - 2
- in: formData
- name: Platform
- type: integer
- - description: Type of created stack (1 - swarm, 2 - compose), required when
- method is file
- enum:
- - 1
- - 2
- in: formData
- name: Type
- type: integer
- - description: required when method is file
- in: formData
- name: file
- type: file
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.CustomTemplate'
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Create a custom template
- tags:
- - custom_templates
- /custom_templates/{id}:
- delete:
- description: |-
- Remove a template.
- **Access policy**: authorized
- operationId: CustomTemplateDelete
- parameters:
- - description: Template identifier
- in: path
- name: id
- required: true
- type: integer
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '403':
- description: Access denied to resource
- '404':
- description: Template not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Remove a template
- tags:
- - custom_templates
- get:
- consumes:
- - application/json
- description: |-
- Retrieve details about a template.
- **Access policy**: authenticated
- operationId: CustomTemplateInspect
- parameters:
- - description: Template identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.CustomTemplate'
- '400':
- description: Invalid request
- '404':
- description: Template not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Inspect a custom template
- tags:
- - custom_templates
- put:
- consumes:
- - application/json
- description: |-
- Update a template.
- **Access policy**: authenticated
- operationId: CustomTemplateUpdate
- parameters:
- - description: Template identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Template details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/customtemplates.customTemplateUpdatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.CustomTemplate'
- '400':
- description: Invalid request
- '403':
- description: Permission denied to access template
- '404':
- description: Template not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update a template
- tags:
- - custom_templates
- /custom_templates/{id}/file:
- get:
- description: |-
- Retrieve the content of the Stack file for the specified custom template
- **Access policy**: authorized
- operationId: CustomTemplateFile
- parameters:
- - description: Template identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/customtemplates.fileResponse'
- '400':
- description: Invalid request
- '404':
- description: Custom template not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Get Template stack file content.
- tags:
- - custom_templates
- /dockerhub:
- get:
- description: |-
- Use this endpoint to retrieve the information used to connect to the DockerHub
- **Access policy**: authenticated
- operationId: DockerHubInspect
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.DockerHub'
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Retrieve DockerHub information
- tags:
- - dockerhub
- put:
- consumes:
- - application/json
- description: |-
- Use this endpoint to update the information used to connect to the DockerHub
- **Access policy**: administrator
- operationId: DockerHubUpdate
- parameters:
- - description: DockerHub information
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/dockerhub.dockerhubUpdatePayload'
- produces:
- - application/json
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update DockerHub information
- tags:
- - dockerhub
- /edge_groups:
- get:
- consumes:
- - application/json
- produces:
- - application/json
- responses:
- '200':
- description: EdgeGroups
- schema:
- items:
- allOf:
- - $ref: '#/definitions/portainer.EdgeGroup'
- - properties:
- HasEdgeStack:
- type: boolean
- type: object
- type: array
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: list EdgeGroups
- tags:
- - edge_groups
- post:
- consumes:
- - application/json
- parameters:
- - description: EdgeGroup data
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/edgegroups.edgeGroupCreatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.EdgeGroup'
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Create an EdgeGroup
- tags:
- - edge_groups
- /edge_groups/{id}:
- delete:
- consumes:
- - application/json
- parameters:
- - description: EdgeGroup Id
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '204':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Deletes an EdgeGroup
- tags:
- - edge_groups
- get:
- consumes:
- - application/json
- parameters:
- - description: EdgeGroup Id
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.EdgeGroup'
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Inspects an EdgeGroup
- tags:
- - edge_groups
- put:
- consumes:
- - application/json
- parameters:
- - description: EdgeGroup Id
- in: path
- name: id
- required: true
- type: integer
- - description: EdgeGroup data
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/edgegroups.edgeGroupUpdatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.EdgeGroup'
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Updates an EdgeGroup
- tags:
- - edge_groups
- /edge_jobs:
- get:
- consumes:
- - application/json
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- items:
- $ref: '#/definitions/portainer.EdgeJob'
- type: array
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Fetch EdgeJobs list
- tags:
- - edge_jobs
- post:
- consumes:
- - application/json
- parameters:
- - description: Creation Method
- enum:
- - file
- - string
- in: query
- name: method
- required: true
- type: string
- - description: EdgeGroup data when method is string
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/edgejobs.edgeJobCreateFromFileContentPayload'
- - description: EdgeGroup data when method is file
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/edgejobs.edgeJobCreateFromFilePayload'
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.EdgeGroup'
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Create an EdgeJob
- tags:
- - edge_jobs
- /edge_jobs/{id}:
- delete:
- consumes:
- - application/json
- parameters:
- - description: EdgeJob Id
- in: path
- name: id
- required: true
- type: string
- produces:
- - application/json
- responses:
- '204':
- description: ''
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Delete an EdgeJob
- tags:
- - edge_jobs
- get:
- consumes:
- - application/json
- parameters:
- - description: EdgeJob Id
- in: path
- name: id
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.EdgeJob'
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Inspect an EdgeJob
- tags:
- - edge_jobs
- post:
- consumes:
- - application/json
- parameters:
- - description: EdgeJob Id
- in: path
- name: id
- required: true
- type: string
- - description: EdgeGroup data
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/edgejobs.edgeJobUpdatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.EdgeJob'
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Update an EdgeJob
- tags:
- - edge_jobs
- /edge_jobs/{id}/file:
- get:
- consumes:
- - application/json
- parameters:
- - description: EdgeJob Id
- in: path
- name: id
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/edgejobs.edgeJobFileResponse'
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Fetch a file of an EdgeJob
- tags:
- - edge_jobs
- /edge_jobs/{id}/tasks:
- get:
- consumes:
- - application/json
- parameters:
- - description: EdgeJob Id
- in: path
- name: id
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- items:
- $ref: '#/definitions/edgejobs.taskContainer'
- type: array
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Fetch the list of tasks on an EdgeJob
- tags:
- - edge_jobs
- /edge_jobs/{id}/tasks/{taskID}/logs:
- delete:
- consumes:
- - application/json
- parameters:
- - description: EdgeJob Id
- in: path
- name: id
- required: true
- type: string
- - description: Task Id
- in: path
- name: taskID
- required: true
- type: string
- produces:
- - application/json
- responses:
- '204':
- description: ''
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Clear the log for a specifc task on an EdgeJob
- tags:
- - edge_jobs
- get:
- consumes:
- - application/json
- parameters:
- - description: EdgeJob Id
- in: path
- name: id
- required: true
- type: string
- - description: Task Id
- in: path
- name: taskID
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/edgejobs.fileResponse'
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Fetch the log for a specifc task on an EdgeJob
- tags:
- - edge_jobs
- post:
- consumes:
- - application/json
- parameters:
- - description: EdgeJob Id
- in: path
- name: id
- required: true
- type: string
- - description: Task Id
- in: path
- name: taskID
- required: true
- type: string
- produces:
- - application/json
- responses:
- '204':
- description: ''
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Collect the log for a specifc task on an EdgeJob
- tags:
- - edge_jobs
- /edge_stacks:
- get:
- consumes:
- - application/json
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- items:
- $ref: '#/definitions/portainer.EdgeStack'
- type: array
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Fetches the list of EdgeStacks
- tags:
- - edge_stacks
- post:
- consumes:
- - application/json
- parameters:
- - description: Creation Method
- enum:
- - file
- - string
- - repository
- in: query
- name: method
- required: true
- type: string
- - description: Required when using method=string
- in: body
- name: body_string
- required: true
- schema:
- $ref: '#/definitions/edgestacks.swarmStackFromFileContentPayload'
- - description: Required when using method=file
- in: body
- name: body_file
- required: true
- schema:
- $ref: '#/definitions/edgestacks.swarmStackFromFileUploadPayload'
- - description: Required when using method=repository
- in: body
- name: body_repository
- required: true
- schema:
- $ref: '#/definitions/edgestacks.swarmStackFromGitRepositoryPayload'
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.EdgeStack'
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Create an EdgeStack
- tags:
- - edge_stacks
- /edge_stacks/{id}:
- delete:
- consumes:
- - application/json
- parameters:
- - description: EdgeStack Id
- in: path
- name: id
- required: true
- type: string
- produces:
- - application/json
- responses:
- '204':
- description: ''
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Delete an EdgeStack
- tags:
- - edge_stacks
- get:
- consumes:
- - application/json
- parameters:
- - description: EdgeStack Id
- in: path
- name: id
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.EdgeStack'
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Inspect an EdgeStack
- tags:
- - edge_stacks
- put:
- consumes:
- - application/json
- parameters:
- - description: EdgeStack Id
- in: path
- name: id
- required: true
- type: string
- - description: EdgeStack data
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/edgestacks.updateEdgeStackPayload'
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.EdgeStack'
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Update an EdgeStack
- tags:
- - edge_stacks
- /edge_stacks/{id}/file:
- get:
- consumes:
- - application/json
- parameters:
- - description: EdgeStack Id
- in: path
- name: id
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/edgestacks.stackFileResponse'
- '400':
- description: ''
- '500':
- description: ''
- '503':
- description: Service Unavailable
- schema:
- type: Edge
- security:
- - jwt: []
- summary: Fetches the stack file for an EdgeStack
- tags:
- - edge_stacks
- /edge_stacks/{id}/status:
- put:
- consumes:
- - application/json
- description: Authorized only if the request is done by an Edge Endpoint
- parameters:
- - description: EdgeStack Id
- in: path
- name: id
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.EdgeStack'
- '400':
- description: ''
- '403':
- description: ''
- '404':
- description: ''
- '500':
- description: ''
- summary: Update an EdgeStack status
- tags:
- - edge_stacks
- /edge_templates:
- get:
- consumes:
- - application/json
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- items:
- $ref: '#/definitions/portainer.Template'
- type: array
- '500':
- description: ''
- security:
- - jwt: []
- summary: Fetches the list of Edge Templates
- tags:
- - edge_templates
- /endpoint_groups:
- get:
- description: |-
- List all endpoint groups based on the current user authorizations. Will
- return all endpoint groups if using an administrator account otherwise it will
- only return authorized endpoint groups.
- **Access policy**: restricted
- operationId: EndpointGroupList
- produces:
- - application/json
- responses:
- '200':
- description: Endpoint group
- schema:
- items:
- $ref: '#/definitions/portainer.EndpointGroup'
- type: array
- '500':
- description: Server error
- security:
- - jwt: []
- summary: List Endpoint groups
- tags:
- - endpoint_groups
- post:
- consumes:
- - application/json
- description: |-
- Create a new endpoint group.
- **Access policy**: administrator
- parameters:
- - description: Endpoint Group details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/endpointgroups.endpointGroupCreatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.EndpointGroup'
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Create an Endpoint Group
- tags:
- - endpoint_groups
- /endpoint_groups/:id:
- get:
- consumes:
- - application/json
- description: |-
- Retrieve details abont an endpoint group.
- **Access policy**: administrator
- parameters:
- - description: Endpoint group identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.EndpointGroup'
- '400':
- description: Invalid request
- '404':
- description: EndpointGroup not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Inspect an Endpoint group
- tags:
- - endpoint_groups
- put:
- consumes:
- - application/json
- description: |-
- Update an endpoint group.
- **Access policy**: administrator
- operationId: EndpointGroupUpdate
- parameters:
- - description: EndpointGroup identifier
- in: path
- name: id
- required: true
- type: integer
- - description: EndpointGroup details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/endpointgroups.endpointGroupUpdatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.EndpointGroup'
- '400':
- description: Invalid request
- '404':
- description: EndpointGroup not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update an endpoint group
- tags:
- - endpoint_groups
- /endpoint_groups/{id}:
- delete:
- consumes:
- - application/json
- description: |-
- Remove an endpoint group.
- **Access policy**: administrator
- operationId: EndpointGroupDelete
- parameters:
- - description: EndpointGroup identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '404':
- description: EndpointGroup not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Remove an endpoint group
- tags:
- - endpoint_groups
- /endpoint_groups/{id}/endpoints/{endpointId}:
- delete:
- description: '**Access policy**: administrator'
- operationId: EndpointGroupDeleteEndpoint
- parameters:
- - description: EndpointGroup identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Endpoint identifier
- in: path
- name: endpointId
- required: true
- type: integer
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '404':
- description: EndpointGroup not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Removes endpoint from an endpoint group
- tags:
- - endpoint_groups
- put:
- description: |-
- Add an endpoint to an endpoint group
- **Access policy**: administrator
- operationId: EndpointGroupAddEndpoint
- parameters:
- - description: EndpointGroup identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Endpoint identifier
- in: path
- name: endpointId
- required: true
- type: integer
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '404':
- description: EndpointGroup not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Add an endpoint to an endpoint group
- tags:
- - endpoint_groups
- /endpoints:
- get:
- description: |-
- List all endpoints based on the current user authorizations. Will
- return all endpoints if using an administrator account otherwise it will
- only return authorized endpoints.
- **Access policy**: restricted
- operationId: EndpointList
- parameters:
- - description: Start searching from
- in: query
- name: start
- type: integer
- - description: Search query
- in: query
- name: search
- type: string
- - description: List endpoints of this group
- in: query
- name: groupId
- type: integer
- - description: Limit results to this value
- in: query
- name: limit
- type: integer
- - description: List endpoints of this type
- in: query
- name: type
- type: integer
- - description: search endpoints with these tags (depends on tagsPartialMatch)
- in: query
- items:
- type: integer
- name: tagIds
- type: array
- - description: If true, will return endpoint which has one of tagIds, if false
- (or missing) will return only endpoints that has all the tags
- in: query
- name: tagsPartialMatch
- type: boolean
- - description: will return only these endpoints
- in: query
- items:
- type: integer
- name: endpointIds
- type: array
- produces:
- - application/json
- responses:
- '200':
- description: Endpoints
- schema:
- items:
- $ref: '#/definitions/portainer.Endpoint'
- type: array
- '500':
- description: Internal Server Error
- schema:
- type: Server
- security:
- - jwt: []
- summary: List endpoints
- tags:
- - endpoints
- post:
- consumes:
- - multipart/form-data
- description: |-
- Create a new endpoint that will be used to manage an environment.
- **Access policy**: administrator
- operationId: EndpointCreate
- parameters:
- - description: 'Name that will be used to identify this endpoint (example: my-endpoint)'
- in: formData
- name: Name
- required: true
- type: string
- - description: 'Environment type. Value must be one of: 1 (Local Docker environment),
- 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment)
- or 5 (Local Kubernetes Environment'
- in: formData
- name: EndpointType
- required: true
- type: integer
- - description: 'URL or IP address of a Docker host (example: docker.mydomain.tld:2375).
- Defaults to local if not specified (Linux: /var/run/docker.sock, Windows:
- //./pipe/docker_engine)'
- in: formData
- name: URL
- type: string
- - description: 'URL or IP address where exposed containers will be reachable.
- Defaults to URL if not specified (example: docker.mydomain.tld:2375)'
- in: formData
- name: PublicURL
- type: string
- - description: Endpoint group identifier. If not specified will default to 1
- (unassigned).
- in: formData
- name: GroupID
- type: integer
- - description: Require TLS to connect against this endpoint
- in: formData
- name: TLS
- type: boolean
- - description: Skip server verification when using TLS
- in: formData
- name: TLSSkipVerify
- type: boolean
- - description: Skip client verification when using TLS
- in: formData
- name: TLSSkipClientVerify
- type: boolean
- - description: TLS CA certificate file
- in: formData
- name: TLSCACertFile
- type: file
- - description: TLS client certificate file
- in: formData
- name: TLSCertFile
- type: file
- - description: TLS client key file
- in: formData
- name: TLSKeyFile
- type: file
- - description: Azure application ID. Required if endpoint type is set to 3
- in: formData
- name: AzureApplicationID
- type: string
- - description: Azure tenant ID. Required if endpoint type is set to 3
- in: formData
- name: AzureTenantID
- type: string
- - description: Azure authentication key. Required if endpoint type is set to
- 3
- in: formData
- name: AzureAuthenticationKey
- type: string
- - description: List of tag identifiers to which this endpoint is associated
- in: formData
- items:
- type: integer
- name: TagIDs
- type: array
- - description: The check in interval for edge agent (in seconds)
- in: formData
- name: EdgeCheckinInterval
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Endpoint'
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Create a new endpoint
- tags:
- - endpoints
- /endpoints/{id}:
- delete:
- description: |-
- Remove an endpoint.
- **Access policy**: administrator
- operationId: EndpointDelete
- parameters:
- - description: Endpoint identifier
- in: path
- name: id
- required: true
- type: integer
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '404':
- description: Endpoint not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Remove an endpoint
- tags:
- - endpoints
- get:
- description: |-
- Retrieve details about an endpoint.
- **Access policy**: restricted
- operationId: EndpointInspect
- parameters:
- - description: Endpoint identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Endpoint'
- '400':
- description: Invalid request
- '404':
- description: Endpoint not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Inspect an endpoint
- tags:
- - endpoints
- put:
- consumes:
- - application/json
- description: |-
- Update an endpoint.
- **Access policy**: administrator
- operationId: EndpointUpdate
- parameters:
- - description: Endpoint identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Endpoint details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/endpoints.endpointUpdatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Endpoint'
- '400':
- description: Invalid request
- '404':
- description: Endpoint not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update an endpoint
- tags:
- - endpoints
- /endpoints/{id}/edge/jobs/{jobID}/logs:
- post:
- consumes:
- - application/json
- parameters:
- - description: Endpoint Id
- in: path
- name: id
- required: true
- type: string
- - description: Job Id
- in: path
- name: jobID
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: ''
- '400':
- description: ''
- '500':
- description: ''
- summary: Inspect an EdgeJob Log
- tags:
- - edge
- - endpoints
- /endpoints/{id}/edge/stacks/{stackId}:
- get:
- consumes:
- - application/json
- parameters:
- - description: Endpoint Id
- in: path
- name: id
- required: true
- type: string
- - description: EdgeStack Id
- in: path
- name: stackID
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/endpointedge.configResponse'
- '400':
- description: ''
- '404':
- description: ''
- '500':
- description: ''
- summary: Inspect an Edge Stack for an Endpoint
- tags:
- - edge
- - endpoints
- - edge_stacks
- /endpoints/{id}/snapshot:
- post:
- description: |-
- Snapshots an endpoint
- **Access policy**: restricted
- operationId: EndpointSnapshot
- parameters:
- - description: Endpoint identifier
- in: path
- name: id
- required: true
- type: integer
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '404':
- description: Endpoint not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Snapshots an endpoint
- tags:
- - endpoints
- /endpoints/{id}/edge/status:
- get:
- description: |-
- Endpoint for edge agent to check status of environment
- **Access policy**: restricted only to Edge endpoints
- operationId: EndpointEdgeStatusInspect
- parameters:
- - description: Endpoint identifier
- in: path
- name: id
- required: true
- type: integer
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/endpoints.endpointEdgeStatusInspectResponse'
- '400':
- description: Invalid request
- '403':
- description: Permission denied to access endpoint
- '404':
- description: Endpoint not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Get endpoint status
- tags:
- - endpoints
- /endpoints/snapshot:
- post:
- description: |-
- Snapshot all endpoints
- **Access policy**: administrator
- operationId: EndpointSnapshots
- responses:
- '204':
- description: Success
- '500':
- description: Server Error
- security:
- - jwt: []
- summary: Snapshot all endpoints
- tags:
- - endpoints
- /motd:
- get:
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/motd.motdResponse'
- security:
- - jwt: []
- summary: fetches the message of the day
- tags:
- - motd
- /registries:
- get:
- description: |-
- List all registries based on the current user authorizations.
- Will return all registries if using an administrator account otherwise it
- will only return authorized registries.
- **Access policy**: restricted
- operationId: RegistryList
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- items:
- $ref: '#/definitions/portainer.Registry'
- type: array
- '500':
- description: Server error
- security:
- - jwt: []
- summary: List Registries
- tags:
- - registries
- post:
- consumes:
- - application/json
- description: |-
- Create a new registry.
- **Access policy**: administrator
- operationId: RegistryCreate
- parameters:
- - description: Registry details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/registries.registryCreatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Registry'
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Create a new registry
- tags:
- - registries
- /registries/{id}:
- delete:
- description: |-
- Remove a registry
- **Access policy**: administrator
- operationId: RegistryDelete
- parameters:
- - description: Registry identifier
- in: path
- name: id
- required: true
- type: integer
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '404':
- description: Registry not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Remove a registry
- tags:
- - registries
- get:
- description: |-
- Retrieve details about a registry.
- **Access policy**: administrator
- operationId: RegistryInspect
- parameters:
- - description: Registry identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Registry'
- '400':
- description: Invalid request
- '403':
- description: Permission denied to access registry
- '404':
- description: Registry not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Inspect a registry
- tags:
- - registries
- put:
- consumes:
- - application/json
- description: |-
- Update a registry
- **Access policy**: administrator
- operationId: RegistryUpdate
- parameters:
- - description: Registry identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Registry details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/registries.registryUpdatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Registry'
- '400':
- description: Invalid request
- '404':
- description: Registry not found
- '409':
- description: Another registry with the same URL already exists
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update a registry
- tags:
- - registries
- /registries/{id}/configure:
- post:
- consumes:
- - application/json
- description: |-
- Configures a registry.
- **Access policy**: admin
- operationId: RegistryConfigure
- parameters:
- - description: Registry identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Registry configuration
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/registries.registryConfigurePayload'
- produces:
- - application/json
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: Registry not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Configures a registry
- tags:
- - registries
- /resource_controls:
- post:
- consumes:
- - application/json
- description: |-
- Create a new resource control to restrict access to a Docker resource.
- **Access policy**: administrator
- operationId: ResourceControlCreate
- parameters:
- - description: Resource control details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/resourcecontrols.resourceControlCreatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.ResourceControl'
- '400':
- description: Invalid request
- '409':
- description: Resource control already exists
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Create a new resource control
- tags:
- - resource_controls
- /resource_controls/{id}:
- delete:
- description: |-
- Remove a resource control.
- **Access policy**: administrator
- parameters:
- - description: Resource control identifier
- in: path
- name: id
- required: true
- type: integer
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '404':
- description: Resource control not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Remove a resource control
- tags:
- - resource_controls
- put:
- consumes:
- - application/json
- description: |-
- Update a resource control
- **Access policy**: restricted
- operationId: ResourceControlUpdate
- parameters:
- - description: Resource control identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Resource control details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/resourcecontrols.resourceControlUpdatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.ResourceControl'
- '400':
- description: Invalid request
- '403':
- description: Unauthorized
- '404':
- description: Resource control not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update a resource control
- tags:
- - resource_controls
- /roles:
- get:
- description: |-
- List all roles available for use
- **Access policy**: administrator
- operationId: RoleList
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- items:
- $ref: '#/definitions/portainer.Role'
- type: array
- '500':
- description: Server error
- security:
- - jwt: []
- summary: List roles
- tags:
- - roles
- /settings:
- get:
- description: |-
- Retrieve Portainer settings.
- **Access policy**: administrator
- operationId: SettingsInspect
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Settings'
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Retrieve Portainer settings
- tags:
- - settings
- put:
- consumes:
- - application/json
- description: |-
- Update Portainer settings.
- **Access policy**: administrator
- operationId: SettingsUpdate
- parameters:
- - description: New settings
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/settings.settingsUpdatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Settings'
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update Portainer settings
- tags:
- - settings
- /settings/ldap/check:
- put:
- consumes:
- - application/json
- description: |-
- Test LDAP connectivity using LDAP details
- **Access policy**: administrator
- operationId: SettingsLDAPCheck
- parameters:
- - description: details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/settings.settingsLDAPCheckPayload'
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Test LDAP connectivity
- tags:
- - settings
- /settings/public:
- get:
- description: |-
- Retrieve public settings. Returns a small set of settings that are not reserved to administrators only.
- **Access policy**: public
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/settings.publicSettingsResponse'
- '500':
- description: Server error
- summary: Retrieve Portainer public settings
- tags:
- - settings
- /stacks:
- get:
- description: |-
- List all stacks based on the current user authorizations.
- Will return all stacks if using an administrator account otherwise it
- will only return the list of stacks the user have access to.
- **Access policy**: restricted
- operationId: StackList
- parameters:
- - description: Filters to process on the stack list. Encoded as JSON (a map[string]string).
- For example, {
- in: query
- name: filters
- type: string
- responses:
- '200':
- description: Success
- schema:
- items:
- $ref: '#/definitions/portainer.Stack'
- type: array
- '204':
- description: Success
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: List stacks
- tags:
- - stacks
- post:
- consumes:
- - application/json
- - ' multipart/form-data'
- description: |-
- Deploy a new stack into a Docker environment specified via the endpoint identifier.
- **Access policy**: restricted
- operationId: StackCreate
- parameters:
- - description: 'Stack deployment type. Possible values: 1 (Swarm stack) or 2
- (Compose stack).'
- enum:
- - 1
- - 2
- in: query
- name: type
- required: true
- type: integer
- - description: 'Stack deployment method. Possible values: file, string or repository.'
- enum:
- - string
- - file
- - repository
- in: query
- name: method
- required: true
- type: string
- - description: Identifier of the endpoint that will be used to deploy the stack
- in: query
- name: endpointId
- required: true
- type: integer
- - description: Required when using method=string and type=1
- in: body
- name: body_swarm_string
- schema:
- $ref: '#/definitions/stacks.swarmStackFromFileContentPayload'
- - description: Required when using method=repository and type=1
- in: body
- name: body_swarm_repository
- schema:
- $ref: '#/definitions/stacks.swarmStackFromGitRepositoryPayload'
- - description: Required when using method=string and type=2
- in: body
- name: body_compose_string
- schema:
- $ref: '#/definitions/stacks.composeStackFromFileContentPayload'
- - description: Required when using method=repository and type=2
- in: body
- name: body_compose_repository
- schema:
- $ref: '#/definitions/stacks.composeStackFromGitRepositoryPayload'
- - description: Name of the stack. required when method is file
- in: formData
- name: Name
- type: string
- - description: Swarm cluster identifier. Required when method equals file and
- type equals 1. required when method is file
- in: formData
- name: SwarmID
- type: string
- - description: "Environment variables passed during deployment, represented
- as a JSON array [{'name': 'name', 'value': 'value'}]. Optional,
- used when method equals file and type equals 1."
- in: formData
- name: Env
- type: string
- - description: Stack file. required when method is file
- in: formData
- name: file
- type: file
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.CustomTemplate'
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Deploy a new stack
- tags:
- - stacks
- /stacks/{id}:
- delete:
- description: |-
- Remove a stack.
- **Access policy**: restricted
- operationId: StackDelete
- parameters:
- - description: Stack identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Set to true to delete an external stack. Only external Swarm
- stacks are supported
- in: query
- name: external
- type: boolean
- - description: Endpoint identifier used to remove an external stack (required
- when external is set to true)
- in: query
- name: endpointId
- type: integer
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: ' not found'
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Remove a stack
- tags:
- - stacks
- get:
- description: |-
- Retrieve details about a stack.
- **Access policy**: restricted
- operationId: StackInspect
- parameters:
- - description: Stack identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Stack'
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: Stack not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Inspect a stack
- tags:
- - stacks
- put:
- consumes:
- - application/json
- description: |-
- Update a stack.
- **Access policy**: restricted
- operationId: StackUpdate
- parameters:
- - description: Stack identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Stacks created before version 1.18.0 might not have an associated
- endpoint identifier. Use this optional parameter to set the endpoint identifier
- used by the stack.
- in: query
- name: endpointId
- type: integer
- - description: Stack details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/stacks.updateSwarmStackPayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Stack'
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: ' not found'
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update a stack
- tags:
- - stacks
- /stacks/{id}/file:
- get:
- description: |-
- Get Stack file content.
- **Access policy**: restricted
- operationId: StackFileInspect
- parameters:
- - description: Stack identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/stacks.stackFileResponse'
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: Stack not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Retrieve the content of the Stack file for the specified stack
- tags:
- - stacks
- /stacks/{id}/migrate:
- post:
- description: |-
- Migrate a stack from an endpoint to another endpoint. It will re-create the stack inside the target endpoint before removing the original stack.
- **Access policy**: restricted
- operationId: StackMigrate
- parameters:
- - description: Stack identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Stacks created before version 1.18.0 might not have an associated
- endpoint identifier. Use this optional parameter to set the endpoint identifier
- used by the stack.
- in: query
- name: endpointId
- type: integer
- - description: Stack migration details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/stacks.stackMigratePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Stack'
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: Stack not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Migrate a stack to another endpoint
- tags:
- - stacks
- /stacks/{id}/start:
- post:
- description: |-
- Starts a stopped Stack.
- **Access policy**: restricted
- operationId: StackStart
- parameters:
- - description: Stack identifier
- in: path
- name: id
- required: true
- type: integer
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Stack'
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: ' not found'
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Starts a stopped Stack
- tags:
- - stacks
- /stacks/{id}/stop:
- post:
- description: |-
- Stops a stopped Stack.
- **Access policy**: restricted
- operationId: StackStop
- parameters:
- - description: Stack identifier
- in: path
- name: id
- required: true
- type: integer
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Stack'
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: ' not found'
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Stops a stopped Stack
- tags:
- - stacks
- /status:
- get:
- description: |-
- Retrieve Portainer status
- **Access policy**: public
- operationId: StatusInspect
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Status'
- summary: Check Portainer status
- tags:
- - status
- /status/version:
- get:
- description: |-
- Check if portainer has an update available
- **Access policy**: authenticated
- operationId: StatusInspectVersion
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/status.inspectVersionResponse'
- security:
- - jwt: []
- summary: Check for portainer updates
- tags:
- - status
- /tags:
- get:
- description: |-
- List tags.
- **Access policy**: administrator
- operationId: TagList
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- items:
- $ref: '#/definitions/portainer.Tag'
- type: array
- '500':
- description: Server error
- security:
- - jwt: []
- summary: List tags
- tags:
- - tags
- post:
- description: |-
- Create a new tag.
- **Access policy**: administrator
- operationId: TagCreate
- parameters:
- - description: Tag details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/tags.tagCreatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Tag'
- '409':
- description: Tag name exists
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Create a new tag
- tags:
- - tags
- /tags/{id}:
- delete:
- consumes:
- - application/json
- description: |-
- Remove a tag.
- **Access policy**: administrator
- operationId: TagDelete
- parameters:
- - description: Tag identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: Tag not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Remove a tag
- tags:
- - tags
- /team:
- post:
- consumes:
- - application/json
- description: |-
- Create a new team.
- **Access policy**: administrator
- operationId: TeamCreate
- parameters:
- - description: details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/teams.teamCreatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Team'
- '400':
- description: Invalid request
- '409':
- description: Team already exists
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Create a new team
- tags:
- - teams
- /team/{id}:
- put:
- consumes:
- - application/json
- description: |-
- Update a team.
- **Access policy**: administrator
- operationId: TeamUpdate
- parameters:
- - description: Team identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Team details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/teams.teamUpdatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Team'
- '204':
- description: Success
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: Team not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update a team
- tags:
- - ''
- /team_memberships:
- get:
- description: |-
- List team memberships. Access is only available to administrators and team leaders.
- **Access policy**: admin
- operationId: TeamMembershipList
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- items:
- $ref: '#/definitions/portainer.TeamMembership'
- type: array
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '500':
- description: Server error
- security:
- - jwt: []
- summary: List team memberships
- tags:
- - team_memberships
- post:
- consumes:
- - application/json
- description: |-
- Create a new team memberships. Access is only available to administrators leaders of the associated team.
- **Access policy**: admin
- operationId: TeamMembershipCreate
- parameters:
- - description: Team membership details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/teammemberships.teamMembershipCreatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.TeamMembership'
- '204':
- description: Success
- '400':
- description: Invalid request
- '403':
- description: Permission denied to manage memberships
- '409':
- description: Team membership already registered
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Create a new team membership
- tags:
- - team_memberships
- /team_memberships/{id}:
- delete:
- description: |-
- Remove a team membership. Access is only available to administrators leaders of the associated team.
- **Access policy**: restricted
- operationId: TeamMembershipDelete
- parameters:
- - description: TeamMembership identifier
- in: path
- name: id
- required: true
- type: integer
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: TeamMembership not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Remove a team membership
- tags:
- - team_memberships
- put:
- consumes:
- - application/json
- description: |-
- Update a team membership. Access is only available to administrators leaders of the associated team.
- **Access policy**: restricted
- operationId: TeamMembershipUpdate
- parameters:
- - description: Team membership identifier
- in: path
- name: id
- required: true
- type: integer
- - description: Team membership details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/teammemberships.teamMembershipUpdatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.TeamMembership'
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: TeamMembership not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update a team membership
- tags:
- - team_memberships
- /teams:
- get:
- description: |-
- List teams. For non-administrator users, will only list the teams they are member of.
- **Access policy**: restricted
- operationId: TeamList
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- items:
- $ref: '#/definitions/portainer.Team'
- type: array
- '500':
- description: Server error
- security:
- - jwt: []
- summary: List teams
- tags:
- - teams
- /teams/{id}:
- delete:
- description: |-
- Remove a team.
- **Access policy**: administrator
- operationId: TeamDelete
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: Team not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Remove a team
- tags:
- - teams
- get:
- description: |-
- Retrieve details about a team. Access is only available for administrator and leaders of that team.
- **Access policy**: restricted
- operationId: TeamInspect
- parameters:
- - description: Team identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.Team'
- '204':
- description: Success
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: Team not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Inspect a team
- tags:
- - teams
- /teams/{id}/memberships:
- get:
- description: |-
- List team memberships. Access is only available to administrators and team leaders.
- **Access policy**: restricted
- operationId: TeamMemberships
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- items:
- $ref: '#/definitions/portainer.TeamMembership'
- type: array
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '500':
- description: Server error
- security:
- - jwt: []
- summary: List team memberships
- tags:
- - team_memberships
- /templates:
- get:
- description: |-
- List available templates.
- **Access policy**: restricted
- operationId: TemplateList
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/templates.listResponse'
- '500':
- description: Server error
- security:
- - jwt: []
- summary: List available templates
- tags:
- - templates
- /templates/file:
- post:
- consumes:
- - application/json
- description: |-
- Get a template's file
- **Access policy**: restricted
- operationId: TemplateFile
- parameters:
- - description: File details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/templates.filePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/templates.fileResponse'
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Get a template's file
- tags:
- - templates
- /upload/tls/{certificate}:
- post:
- consumes:
- - multipart/form-data
- description: |-
- Use this endpoint to upload TLS files.
- **Access policy**: administrator
- operationId: UploadTLS
- parameters:
- - description: TLS file type. Valid values are 'ca', 'cert' or 'key'.
- enum:
- - ca
- - cert
- - key
- in: path
- name: certificate
- required: true
- type: string
- - description: Folder where the TLS file will be stored. Will be created if
- not existing
- in: formData
- name: folder
- required: true
- type: string
- - description: The file to upload
- in: formData
- name: file
- required: true
- type: file
- produces:
- - application/json
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Upload TLS files
- tags:
- - upload
- /users:
- get:
- description: |-
- List Portainer users.
- Non-administrator users will only be able to list other non-administrator user accounts.
- **Access policy**: restricted
- operationId: UserList
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- items:
- $ref: '#/definitions/portainer.User'
- type: array
- '400':
- description: Invalid request
- '500':
- description: Server error
- security:
- - jwt: []
- summary: List users
- tags:
- - users
- post:
- consumes:
- - application/json
- description: |-
- Create a new Portainer user.
- Only team leaders and administrators can create users.
- Only administrators can create an administrator user account.
- **Access policy**: restricted
- operationId: UserCreate
- parameters:
- - description: User details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/users.userCreatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.User'
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '409':
- description: User already exists
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Create a new user
- tags:
- - users
- /users/{id}:
- delete:
- consumes:
- - application/json
- description: |-
- Remove a user.
- **Access policy**: administrator
- operationId: UserDelete
- parameters:
- - description: User identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: User not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Remove a user
- tags:
- - users
- get:
- description: |-
- Retrieve details about a user.
- **Access policy**: administrator
- operationId: UserInspect
- parameters:
- - description: User identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.User'
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: User not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Inspect a user
- tags:
- - users
- put:
- consumes:
- - application/json
- description: |-
- Update user details. A regular user account can only update his details.
- **Access policy**: authenticated
- operationId: UserUpdate
- parameters:
- - description: User identifier
- in: path
- name: id
- required: true
- type: integer
- - description: User details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/users.userUpdatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.User'
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: User not found
- '409':
- description: Username already exist
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update a user
- tags:
- - users
- /users/{id}/memberships:
- get:
- description: |-
- Inspect a user memberships.
- **Access policy**: authenticated
- operationId: UserMembershipsInspect
- parameters:
- - description: User identifier
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.TeamMembership'
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Inspect a user memberships
- tags:
- - users
- /users/{id}/passwd:
- put:
- consumes:
- - application/json
- description: |-
- Update password for the specified user.
- **Access policy**: authenticated
- operationId: UserUpdatePassword
- parameters:
- - description: identifier
- in: path
- name: id
- required: true
- type: integer
- - description: details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/users.userUpdatePasswordPayload'
- produces:
- - application/json
- responses:
- '204':
- description: Success
- '400':
- description: Invalid request
- '403':
- description: Permission denied
- '404':
- description: User not found
- '500':
- description: Server error
- security:
- - jwt: []
- summary: Update password for a user
- tags:
- - users
- /users/admin/check:
- get:
- description: |-
- Check if an administrator account exists in the database.
- **Access policy**: public
- operationId: UserAdminCheck
- responses:
- '204':
- description: Success
- '404':
- description: User not found
- summary: Check administrator account existence
- tags:
- - users
- /users/admin/init:
- post:
- consumes:
- - application/json
- description: |-
- Initialize the 'admin' user account.
- **Access policy**: public
- operationId: UserAdminInit
- parameters:
- - description: User details
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/users.adminInitPayload'
- produces:
- - application/json
- responses:
- '200':
- description: Success
- schema:
- $ref: '#/definitions/portainer.User'
- '400':
- description: Invalid request
- '409':
- description: Admin user already initialized
- '500':
- description: Server error
- summary: Initialize administrator account
- tags:
- - ''
- /webhooks:
- get:
- consumes:
- - application/json
- parameters:
- - description: Webhook data
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/webhooks.webhookCreatePayload'
- - in: query
- name: EndpointID
- type: integer
- - in: query
- name: ResourceID
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- items:
- $ref: '#/definitions/portainer.Webhook'
- type: array
- '400':
- description: ''
- '500':
- description: ''
- security:
- - jwt: []
- summary: List webhooks
- tags:
- - webhooks
- post:
- consumes:
- - application/json
- parameters:
- - description: Webhook data
- in: body
- name: body
- required: true
- schema:
- $ref: '#/definitions/webhooks.webhookCreatePayload'
- produces:
- - application/json
- responses:
- '200':
- description: OK
- schema:
- $ref: '#/definitions/portainer.Webhook'
- '400':
- description: ''
- '409':
- description: ''
- '500':
- description: ''
- security:
- - jwt: []
- summary: Create a webhook
- tags:
- - webhooks
- /webhooks/{id}:
- delete:
- consumes:
- - application/json
- parameters:
- - description: Webhook id
- in: path
- name: id
- required: true
- type: integer
- produces:
- - application/json
- responses:
- '202':
- description: Webhook deleted
- '400':
- description: ''
- '500':
- description: ''
- security:
- - jwt: []
- summary: Delete a webhook
- tags:
- - webhooks
- /webhooks/{token}:
- post:
- consumes:
- - application/json
- description: Acts on a passed in token UUID to restart the docker service
- parameters:
- - description: Webhook token
- in: path
- name: token
- required: true
- type: string
- produces:
- - application/json
- responses:
- '202':
- description: Webhook executed
- '400':
- description: ''
- '500':
- description: ''
- summary: Execute a webhook
- tags:
- - webhooks
- /websocket/attach:
- get:
- consumes:
- - application/json
- description: |-
- If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint.
- If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and
- an AttachStart operation HTTP request will be created and hijacked.
- Authentication and access is controlled via the mandatory token query parameter.
- parameters:
- - description: endpoint ID of the endpoint where the resource is located
- in: query
- name: endpointId
- required: true
- type: integer
- - description: node name
- in: query
- name: nodeName
- type: string
- - description: JWT token used for authentication against this endpoint
- in: query
- name: token
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: ''
- '400':
- description: ''
- '403':
- description: ''
- '404':
- description: ''
- '500':
- description: ''
- security:
- - jwt: []
- summary: Attach a websocket
- tags:
- - websocket
- /websocket/exec:
- get:
- consumes:
- - application/json
- description: |-
- If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint.
- If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and
- an ExecStart operation HTTP request will be created and hijacked.
- Authentication and access is controlled via the mandatory token query parameter.
- parameters:
- - description: endpoint ID of the endpoint where the resource is located
- in: query
- name: endpointId
- required: true
- type: integer
- - description: node name
- in: query
- name: nodeName
- type: string
- - description: JWT token used for authentication against this endpoint
- in: query
- name: token
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: ''
- '400':
- description: ''
- '409':
- description: ''
- '500':
- description: ''
- security:
- - jwt: []
- summary: Execute a websocket
- tags:
- - websocket
- /websocket/pod:
- get:
- consumes:
- - application/json
- description: |-
- The request will be upgraded to the websocket protocol.
- Authentication and access is controlled via the mandatory token query parameter.
- parameters:
- - description: endpoint ID of the endpoint where the resource is located
- in: query
- name: endpointId
- required: true
- type: integer
- - description: namespace where the container is located
- in: query
- name: namespace
- required: true
- type: string
- - description: name of the pod containing the container
- in: query
- name: podName
- required: true
- type: string
- - description: name of the container
- in: query
- name: containerName
- required: true
- type: string
- - description: command to execute in the container
- in: query
- name: command
- required: true
- type: string
- - description: JWT token used for authentication against this endpoint
- in: query
- name: token
- required: true
- type: string
- produces:
- - application/json
- responses:
- '200':
- description: ''
- '400':
- description: ''
- '403':
- description: ''
- '404':
- description: ''
- '500':
- description: ''
- security:
- - jwt: []
- summary: Execute a websocket on pod
- tags:
- - websocket
-schemes:
- - http
- - https
-securityDefinitions:
- jwt:
- in: header
- name: Authorization
- type: apiKey
-swagger: '2.0'
-tags:
- - description: Authenticate against Portainer HTTP API
- name: auth
- - description: Manage Custom Templates
- name: custom_templates
- - description: Manage how Portainer connects to the DockerHub
- name: dockerhub
- - description: Manage Edge Groups
- name: edge_groups
- - description: Manage Edge Jobs
- name: edge_jobs
- - description: Manage Edge Stacks
- name: edge_stacks
- - description: Manage Edge Templates
- name: edge_templates
- - description: Manage Edge related endpoint settings
- name: edge
- - description: Manage Docker environments
- name: endpoints
- - description: Manage endpoint groups
- name: endpoint_groups
- - description: Fetch the message of the day
- name: motd
- - description: Manage Docker registries
- name: registries
- - description: Manage access control on Docker resources
- name: resource_controls
- - description: Manage roles
- name: roles
- - description: Manage Portainer settings
- name: settings
- - description: Information about the Portainer instance
- name: status
- - description: Manage Docker stacks
- name: stacks
- - description: Manage users
- name: users
- - description: Manage tags
- name: tags
- - description: Manage teams
- name: teams
- - description: Manage team memberships
- name: team_memberships
- - description: Manage App Templates
- name: templates
- - description: Manage stacks
- name: stacks
- - description: Upload files
- name: upload
- - description: Manage webhooks
- name: webhooks
- - description: Create exec sessions using websockets
- name: websocket
From 5526fd8296c0e576975e7336f7ff644d792c2dae Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Thu, 27 Feb 2025 11:02:25 +1300
Subject: [PATCH 031/212] chore: bump 2.27.1 - develop (#468)
---
.github/ISSUE_TEMPLATE/bug_report.yml | 1 +
api/datastore/test_data/output_24_to_latest.json | 4 ++--
api/http/handler/handler.go | 2 +-
api/portainer.go | 2 +-
package.json | 2 +-
5 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index e2eef4f66..99d319aaf 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -95,6 +95,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 [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
+ - '2.27.1'
- '2.27.0'
- '2.26.1'
- '2.26.0'
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index a9ebeffb5..8c5dc01e7 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -610,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
- "KubectlShellImage": "portainer/kubectl-shell:2.27.0",
+ "KubectlShellImage": "portainer/kubectl-shell:2.27.1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
- "VERSION": "{\"SchemaVersion\":\"2.27.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
+ "VERSION": "{\"SchemaVersion\":\"2.27.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
\ No newline at end of file
diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go
index 4877e76c7..7603e345b 100644
--- a/api/http/handler/handler.go
+++ b/api/http/handler/handler.go
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
-// @version 2.27.0
+// @version 2.27.1
// @description.markdown api-description.md
// @termsOfService
diff --git a/api/portainer.go b/api/portainer.go
index 1195737d8..990747813 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -1637,7 +1637,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
- APIVersion = "2.27.0"
+ APIVersion = "2.27.1"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
// Edition is what this edition of Portainer is called
diff --git a/package.json b/package.json
index 41d07244e..2068b5454 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
- "version": "2.27.0",
+ "version": "2.27.1",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"
From 8e6d0e7d42a15595fd2954aacbf411e9d3477924 Mon Sep 17 00:00:00 2001
From: Malcolm Lockyer
Date: Fri, 28 Feb 2025 14:41:54 +1300
Subject: [PATCH 032/212] perf(endpointrelation): Part 2 of fixing
endpointrelation perf [be-11616] (#471)
---
.../endpointrelation/endpointrelation.go | 14 +++++
api/dataservices/endpointrelation/tx.go | 54 +++++++++++++++++
api/dataservices/interface.go | 2 +
.../handler/edgestacks/edgestack_update.go | 58 ++++---------------
api/internal/edge/edgestacks/service.go | 27 ++-------
api/internal/testhelpers/datastore.go | 26 +++++++++
6 files changed, 112 insertions(+), 69 deletions(-)
diff --git a/api/dataservices/endpointrelation/endpointrelation.go b/api/dataservices/endpointrelation/endpointrelation.go
index 4b7ff6b82..a81c258b9 100644
--- a/api/dataservices/endpointrelation/endpointrelation.go
+++ b/api/dataservices/endpointrelation/endpointrelation.go
@@ -22,6 +22,8 @@ type Service struct {
mu sync.Mutex
}
+var _ dataservices.EndpointRelationService = &Service{}
+
func (service *Service) BucketName() string {
return BucketName
}
@@ -109,6 +111,18 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
return nil
}
+func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
+ return service.connection.ViewTx(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.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
+ })
+}
+
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
deletedRelation, _ := service.EndpointRelation(endpointID)
diff --git a/api/dataservices/endpointrelation/tx.go b/api/dataservices/endpointrelation/tx.go
index 097748767..2b2d90280 100644
--- a/api/dataservices/endpointrelation/tx.go
+++ b/api/dataservices/endpointrelation/tx.go
@@ -13,6 +13,8 @@ type ServiceTx struct {
tx portainer.Transaction
}
+var _ dataservices.EndpointRelationService = &ServiceTx{}
+
func (service ServiceTx) BucketName() string {
return BucketName
}
@@ -74,6 +76,58 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
return nil
}
+func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
+ for _, endpointID := range endpointIDs {
+ rel, err := service.EndpointRelation(endpointID)
+ if err != nil {
+ return err
+ }
+
+ rel.EdgeStacks[edgeStackID] = true
+
+ identifier := service.service.connection.ConvertToKey(int(endpointID))
+ err = service.tx.UpdateObject(BucketName, identifier, rel)
+ cache.Del(endpointID)
+ if err != nil {
+ return err
+ }
+ }
+
+ if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
+ edgeStack.NumDeployments += len(endpointIDs)
+ }); err != nil {
+ log.Error().Err(err).Msg("could not update the number of deployments")
+ }
+
+ return nil
+}
+
+func (service ServiceTx) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
+ for _, endpointID := range endpointIDs {
+ rel, err := service.EndpointRelation(endpointID)
+ if err != nil {
+ return err
+ }
+
+ delete(rel.EdgeStacks, edgeStackID)
+
+ identifier := service.service.connection.ConvertToKey(int(endpointID))
+ err = service.tx.UpdateObject(BucketName, identifier, rel)
+ cache.Del(endpointID)
+ if err != nil {
+ return err
+ }
+ }
+
+ if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
+ edgeStack.NumDeployments -= len(endpointIDs)
+ }); err != nil {
+ log.Error().Err(err).Msg("could not update the number of deployments")
+ }
+
+ return nil
+}
+
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
deletedRelation, _ := service.EndpointRelation(endpointID)
diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go
index 1efef4f70..2bc2df7f3 100644
--- a/api/dataservices/interface.go
+++ b/api/dataservices/interface.go
@@ -115,6 +115,8 @@ type (
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
Create(endpointRelation *portainer.EndpointRelation) error
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
+ AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
+ RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
BucketName() string
}
diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go
index 593e403ef..27a279fb3 100644
--- a/api/http/handler/edgestacks/edgestack_update.go
+++ b/api/http/handler/edgestacks/edgestack_update.go
@@ -138,57 +138,19 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
}
- oldRelatedSet := set.ToSet(oldRelatedEnvironmentIDs)
- newRelatedSet := set.ToSet(newRelatedEnvironmentIDs)
+ oldRelatedEnvironmentsSet := set.ToSet(oldRelatedEnvironmentIDs)
+ newRelatedEnvironmentsSet := set.ToSet(newRelatedEnvironmentIDs)
- endpointsToRemove := set.Set[portainer.EndpointID]{}
- for endpointID := range oldRelatedSet {
- if !newRelatedSet[endpointID] {
- endpointsToRemove[endpointID] = true
- }
+ relatedEnvironmentsToAdd := newRelatedEnvironmentsSet.Difference(oldRelatedEnvironmentsSet)
+ relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
+
+ if len(relatedEnvironmentsToRemove) > 0 {
+ tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID)
}
- for endpointID := range endpointsToRemove {
- relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
- if err != nil {
- if tx.IsErrObjectNotFound(err) {
- continue
- }
- return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
- }
-
- delete(relation.EdgeStacks, edgeStackID)
-
- if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
- return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
- }
+ if len(relatedEnvironmentsToAdd) > 0 {
+ tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID)
}
- endpointsToAdd := set.Set[portainer.EndpointID]{}
- for endpointID := range newRelatedSet {
- if !oldRelatedSet[endpointID] {
- endpointsToAdd[endpointID] = true
- }
- }
-
- for endpointID := range endpointsToAdd {
- relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
- if err != nil && !tx.IsErrObjectNotFound(err) {
- return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
- }
-
- if relation == nil {
- relation = &portainer.EndpointRelation{
- EndpointID: endpointID,
- EdgeStacks: map[portainer.EdgeStackID]bool{},
- }
- }
- relation.EdgeStacks[edgeStackID] = true
-
- if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
- return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
- }
- }
-
- return newRelatedEnvironmentIDs, endpointsToAdd, nil
+ return newRelatedEnvironmentIDs, relatedEnvironmentsToAdd, nil
}
diff --git a/api/internal/edge/edgestacks/service.go b/api/internal/edge/edgestacks/service.go
index 3a19e8c9d..6986a6917 100644
--- a/api/internal/edge/edgestacks/service.go
+++ b/api/internal/edge/edgestacks/service.go
@@ -11,7 +11,6 @@ import (
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/internal/edge"
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
- "github.com/rs/zerolog/log"
"github.com/pkg/errors"
)
@@ -100,12 +99,15 @@ func (service *Service) PersistEdgeStack(
stack.ManifestPath = manifestPath
stack.ProjectPath = projectPath
stack.EntryPoint = composePath
- stack.NumDeployments = len(relatedEndpointIds)
if err := tx.EdgeStack().Create(stack.ID, stack); err != nil {
return nil, err
}
+ if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil {
+ return nil, fmt.Errorf("unable to add endpoint relations: %w", err)
+ }
+
if err := service.updateEndpointRelations(tx, stack.ID, relatedEndpointIds); err != nil {
return nil, fmt.Errorf("unable to update endpoint relations: %w", err)
}
@@ -148,25 +150,8 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
return errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
}
- for _, endpointID := range relatedEndpointIds {
- relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
- if err != nil {
- if tx.IsErrObjectNotFound(err) {
- log.Warn().
- Int("endpoint_id", int(endpointID)).
- Msg("Unable to find endpoint relation in database, skipping")
-
- continue
- }
-
- return errors.WithMessage(err, "Unable to find environment relation in database")
- }
-
- delete(relation.EdgeStacks, edgeStackID)
-
- if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
- return errors.WithMessage(err, "Unable to persist environment relation in database")
- }
+ if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEndpointIds, edgeStackID); err != nil {
+ return errors.WithMessage(err, "unable to remove environment relation in database")
}
if err := tx.EdgeStack().DeleteEdgeStack(edgeStackID); err != nil {
diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go
index f0bba23fd..19d4df762 100644
--- a/api/internal/testhelpers/datastore.go
+++ b/api/internal/testhelpers/datastore.go
@@ -9,6 +9,8 @@ import (
"github.com/portainer/portainer/api/dataservices/errors"
)
+var _ dataservices.DataStore = &testDatastore{}
+
type testDatastore struct {
customTemplate dataservices.CustomTemplateService
edgeGroup dataservices.EdgeGroupService
@@ -227,6 +229,30 @@ func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.Endpoi
return nil
}
+func (s *stubEndpointRelationService) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
+ for _, endpointID := range endpointIDs {
+ for i, r := range s.relations {
+ if r.EndpointID == endpointID {
+ s.relations[i].EdgeStacks[edgeStackID] = true
+ }
+ }
+ }
+
+ return nil
+}
+
+func (s *stubEndpointRelationService) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
+ for _, endpointID := range endpointIDs {
+ for i, r := range s.relations {
+ if r.EndpointID == endpointID {
+ delete(s.relations[i].EdgeStacks, edgeStackID)
+ }
+ }
+ }
+
+ return nil
+}
+
func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
return nil
}
From 52bb06eb7b973c786886696eb05cc43d5f565b64 Mon Sep 17 00:00:00 2001
From: James Player
Date: Mon, 3 Mar 2025 11:29:58 +1300
Subject: [PATCH 033/212] chore(helm): Convert helm details view to react
(#476)
---
app/kubernetes/react/views/index.ts | 5 +
.../applications/helm/helm.controller.js | 52 --------
.../views/applications/helm/helm.css | 5 -
.../views/applications/helm/helm.html | 50 --------
.../views/applications/helm/index.js | 11 --
.../HelmApplicationView.test.tsx | 119 ++++++++++++++++++
.../HelmApplicationView.tsx | 79 ++++++++++++
.../helm/HelmApplicationView/index.ts | 1 +
.../queries/useHelmRelease.ts | 83 ++++++++++++
9 files changed, 287 insertions(+), 118 deletions(-)
delete mode 100644 app/kubernetes/views/applications/helm/helm.controller.js
delete mode 100644 app/kubernetes/views/applications/helm/helm.css
delete mode 100644 app/kubernetes/views/applications/helm/helm.html
delete mode 100644 app/kubernetes/views/applications/helm/index.js
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/index.ts
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts
index 7913f7641..e4043985f 100644
--- a/app/kubernetes/react/views/index.ts
+++ b/app/kubernetes/react/views/index.ts
@@ -23,6 +23,7 @@ import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceV
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
import { ClusterView } from '@/react/kubernetes/cluster/ClusterView';
+import { HelmApplicationView } from '@/react/kubernetes/helm/HelmApplicationView';
export const viewsModule = angular
.module('portainer.kubernetes.react.views', [])
@@ -79,6 +80,10 @@ export const viewsModule = angular
[]
)
)
+ .component(
+ 'kubernetesHelmApplicationView',
+ r2a(withUIRouter(withReactQuery(withCurrentUser(HelmApplicationView))), [])
+ )
.component(
'kubernetesClusterView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), [])
diff --git a/app/kubernetes/views/applications/helm/helm.controller.js b/app/kubernetes/views/applications/helm/helm.controller.js
deleted file mode 100644
index 0b027c2ef..000000000
--- a/app/kubernetes/views/applications/helm/helm.controller.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import PortainerError from 'Portainer/error';
-
-export default class KubernetesHelmApplicationController {
- /* @ngInject */
- constructor($async, $state, Authentication, Notifications, HelmService) {
- this.$async = $async;
- this.$state = $state;
- this.Authentication = Authentication;
- this.Notifications = Notifications;
- this.HelmService = HelmService;
- }
-
- /**
- * APPLICATION
- */
- async getHelmApplication() {
- try {
- this.state.dataLoading = true;
- const releases = await this.HelmService.listReleases(this.endpoint.Id, { filter: `^${this.state.params.name}$`, namespace: this.state.params.namespace });
- if (releases.length > 0) {
- this.state.release = releases[0];
- } else {
- throw new PortainerError(`Release ${this.state.params.name} not found`);
- }
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve helm application details');
- } finally {
- this.state.dataLoading = false;
- }
- }
-
- $onInit() {
- return this.$async(async () => {
- this.state = {
- dataLoading: true,
- viewReady: false,
- params: {
- name: this.$state.params.name,
- namespace: this.$state.params.namespace,
- },
- release: {
- name: undefined,
- chart: undefined,
- app_version: undefined,
- },
- };
-
- await this.getHelmApplication();
- this.state.viewReady = true;
- });
- }
-}
diff --git a/app/kubernetes/views/applications/helm/helm.css b/app/kubernetes/views/applications/helm/helm.css
deleted file mode 100644
index 784f878fd..000000000
--- a/app/kubernetes/views/applications/helm/helm.css
+++ /dev/null
@@ -1,5 +0,0 @@
-.release-table tr {
- display: grid;
- grid-auto-flow: column;
- grid-template-columns: 1fr 4fr;
-}
diff --git a/app/kubernetes/views/applications/helm/helm.html b/app/kubernetes/views/applications/helm/helm.html
deleted file mode 100644
index a815e8a9d..000000000
--- a/app/kubernetes/views/applications/helm/helm.html
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- Name
-
- {{ $ctrl.state.release.name }}
-
-
-
- Chart
-
- {{ $ctrl.state.release.chart }}
-
-
-
- App version
-
- {{ $ctrl.state.release.app_version }}
-
-
-
-
-
-
-
-
-
diff --git a/app/kubernetes/views/applications/helm/index.js b/app/kubernetes/views/applications/helm/index.js
deleted file mode 100644
index b99f41d4b..000000000
--- a/app/kubernetes/views/applications/helm/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import angular from 'angular';
-import controller from './helm.controller';
-import './helm.css';
-
-angular.module('portainer.kubernetes').component('kubernetesHelmApplicationView', {
- templateUrl: './helm.html',
- controller,
- bindings: {
- endpoint: '<',
- },
-});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
new file mode 100644
index 000000000..296b17ff5
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
@@ -0,0 +1,119 @@
+import { render, screen } from '@testing-library/react';
+import { HttpResponse } from 'msw';
+
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+import { server, http } from '@/setup-tests/server';
+import { withTestRouter } from '@/react/test-utils/withRouter';
+import { UserViewModel } from '@/portainer/models/user';
+import { withUserProvider } from '@/react/test-utils/withUserProvider';
+
+import { HelmApplicationView } from './HelmApplicationView';
+
+// Mock the necessary hooks and dependencies
+const mockUseCurrentStateAndParams = vi.fn();
+const mockUseEnvironmentId = vi.fn();
+
+vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({
+ ...(await importOriginal()),
+ useCurrentStateAndParams: () => mockUseCurrentStateAndParams(),
+}));
+
+vi.mock('@/react/hooks/useEnvironmentId', () => ({
+ useEnvironmentId: () => mockUseEnvironmentId(),
+}));
+
+function renderComponent() {
+ const user = new UserViewModel({ Username: 'user' });
+ const Wrapped = withTestQueryProvider(
+ withUserProvider(withTestRouter(HelmApplicationView), user)
+ );
+ return render( );
+}
+
+describe('HelmApplicationView', () => {
+ beforeEach(() => {
+ // Set up default mock values
+ mockUseEnvironmentId.mockReturnValue(3);
+ mockUseCurrentStateAndParams.mockReturnValue({
+ params: {
+ name: 'test-release',
+ namespace: 'default',
+ },
+ });
+
+ // Set up default mock API responses
+ server.use(
+ http.get('/api/endpoints/3/kubernetes/helm', () =>
+ HttpResponse.json([
+ {
+ name: 'test-release',
+ chart: 'test-chart-1.0.0',
+ app_version: '1.0.0',
+ },
+ ])
+ )
+ );
+ });
+
+ it('should display helm release details when data is loaded', async () => {
+ renderComponent();
+
+ // Check for the page header
+ expect(await screen.findByText('Helm details')).toBeInTheDocument();
+
+ // Check for the release details
+ expect(screen.getByText('Release')).toBeInTheDocument();
+
+ // Check for the table content
+ expect(screen.getByText('Name')).toBeInTheDocument();
+ expect(screen.getByText('Chart')).toBeInTheDocument();
+ expect(screen.getByText('App version')).toBeInTheDocument();
+
+ // Check for the actual values
+ expect(screen.getByTestId('k8sAppDetail-appName')).toHaveTextContent(
+ 'test-release'
+ );
+ expect(screen.getByText('test-chart-1.0.0')).toBeInTheDocument();
+ expect(screen.getByText('1.0.0')).toBeInTheDocument();
+ });
+
+ it('should display error message when API request fails', async () => {
+ // Mock API failure
+ server.use(
+ http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.error())
+ );
+
+ // Mock console.error to prevent test output pollution
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ renderComponent();
+
+ // Wait for the error message to appear
+ expect(
+ await screen.findByText('Failed to load Helm application details')
+ ).toBeInTheDocument();
+
+ // Restore console.error
+ vi.spyOn(console, 'error').mockRestore();
+ });
+
+ it('should display error message when release is not found', async () => {
+ // Mock empty response (no releases found)
+ server.use(
+ http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.json([]))
+ );
+
+ // Mock console.error to prevent test output pollution
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ renderComponent();
+
+ // Wait for the error message to appear
+ expect(
+ await screen.findByText('Failed to load Helm application details')
+ ).toBeInTheDocument();
+
+ // Restore console.error
+ vi.spyOn(console, 'error').mockRestore();
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
new file mode 100644
index 000000000..f50b59154
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
@@ -0,0 +1,79 @@
+import { useCurrentStateAndParams } from '@uirouter/react';
+
+import { PageHeader } from '@/react/components/PageHeader';
+import { Widget, WidgetBody, WidgetTitle } from '@/react/components/Widget';
+import helm from '@/assets/ico/vendor/helm.svg?c';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { ViewLoading } from '@@/ViewLoading';
+import { Alert } from '@@/Alert';
+
+import { useHelmRelease } from './queries/useHelmRelease';
+
+export function HelmApplicationView() {
+ const { params } = useCurrentStateAndParams();
+ const environmentId = useEnvironmentId();
+
+ const name = params.name as string;
+ const namespace = params.namespace as string;
+
+ const {
+ data: release,
+ isLoading,
+ error,
+ } = useHelmRelease(environmentId, name, namespace);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error || !release) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ Name
+
+ {release.name}
+
+
+
+ Chart
+ {release.chart}
+
+
+ App version
+ {release.app_version}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/index.ts b/app/react/kubernetes/helm/HelmApplicationView/index.ts
new file mode 100644
index 000000000..e3adc8d92
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/index.ts
@@ -0,0 +1 @@
+export { HelmApplicationView } from './HelmApplicationView';
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
new file mode 100644
index 000000000..b7cfcb91c
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
@@ -0,0 +1,83 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { withGlobalError } from '@/react-tools/react-query';
+import PortainerError from 'Portainer/error';
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+
+interface HelmRelease {
+ name: string;
+ chart: string;
+ app_version: string;
+}
+/**
+ * List all helm releases based on passed in options
+ * @param environmentId - Environment ID
+ * @param options - Options for filtering releases
+ * @returns List of helm releases
+ */
+export async function listReleases(
+ environmentId: EnvironmentId,
+ options: {
+ namespace?: string;
+ filter?: string;
+ selector?: string;
+ output?: string;
+ } = {}
+): Promise {
+ try {
+ const { namespace, filter, selector, output } = options;
+ const url = `endpoints/${environmentId}/kubernetes/helm`;
+ const { data } = await axios.get(url, {
+ params: { namespace, filter, selector, output },
+ });
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error, 'Unable to retrieve release list');
+ }
+}
+
+/**
+ * React hook to fetch a specific Helm release
+ */
+export function useHelmRelease(
+ environmentId: EnvironmentId,
+ name: string,
+ namespace: string
+) {
+ return useQuery(
+ [environmentId, 'helm', namespace, name],
+ () => getHelmRelease(environmentId, name, namespace),
+ {
+ enabled: !!environmentId,
+ ...withGlobalError('Unable to retrieve helm application details'),
+ }
+ );
+}
+
+/**
+ * Get a specific Helm release
+ */
+async function getHelmRelease(
+ environmentId: EnvironmentId,
+ name: string,
+ namespace: string
+): Promise {
+ try {
+ const releases = await listReleases(environmentId, {
+ filter: `^${name}$`,
+ namespace,
+ });
+
+ if (releases.length > 0) {
+ return releases[0];
+ }
+
+ throw new PortainerError(`Release ${name} not found`);
+ } catch (err) {
+ throw new PortainerError(
+ 'Unable to retrieve helm application details',
+ err as Error
+ );
+ }
+}
From 2bccb3589e37ae3e44f278ad8fe3ffc137848f39 Mon Sep 17 00:00:00 2001
From: LP B
Date: Wed, 5 Mar 2025 16:04:16 +0100
Subject: [PATCH 034/212] fix(app/images): nodeName on images list links (#484)
---
.../ListView/ImagesDatatable/columns/id.tsx | 30 +++++++++----------
1 file changed, 14 insertions(+), 16 deletions(-)
diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx
index 00081aa7e..d0de59dfe 100644
--- a/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx
+++ b/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx
@@ -1,5 +1,4 @@
import { CellContext, Column } from '@tanstack/react-table';
-import { useSref } from '@uirouter/react';
import { truncate } from '@/portainer/filters/filters';
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
@@ -7,6 +6,7 @@ import { ImagesListResponse } from '@/react/docker/images/queries/useImages';
import { MultipleSelectionFilter } from '@@/datatables/Filter';
import { UnusedBadge } from '@@/Badge/UnusedBadge';
+import { Link } from '@@/Link';
import { columnHelper } from './helper';
@@ -62,22 +62,20 @@ function FilterByUsage({
}
function Cell({
- getValue,
- row: { original: image },
+ row: { original: item },
}: CellContext) {
- const name = getValue();
-
- const linkProps = useSref('.image', {
- id: image.id,
- imageId: image.id,
- });
-
return (
-
+ <>
+
+ {truncate(item.id, 40)}
+
+ {!item.used && }
+ >
);
}
From 438b1f981535606ff4fc6b6d450e7ef828742a02 Mon Sep 17 00:00:00 2001
From: Cara Ryan
Date: Thu, 6 Mar 2025 09:35:31 +1300
Subject: [PATCH 035/212] fix(helm): Remove duplicate helm instructions in CE
[BE-11670] (#482)
---
.../helm-templates-list.html | 58 +++++++++----------
1 file changed, 26 insertions(+), 32 deletions(-)
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
index 504c53af3..cd2c1131d 100644
--- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
+++ b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
@@ -31,37 +31,31 @@
>Select the Helm chart to use. Bring further Helm charts into your selection list via
User settings - Helm repositories .
-
-
Select the Helm chart to use. Bring further Helm charts into your selection list via
-
User settings - Helm repositories .
-
-
-
-
Disclaimer
-
- At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.
- If you would like to provide feedback on OCI support or get access to early releases to test this functionality,
-
please get in touch .
-
+
+
+
+
Disclaimer
+
+ At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.
+ If you would like to provide feedback on OCI support or get access to early releases to test this functionality,
+
please get in touch .
@@ -75,7 +69,7 @@
on-select="($ctrl.selectAction)"
>
-
No Helm charts found
+
No Helm charts found
Loading...
Initial download of Helm charts can take a few minutes
From b57855f20d9520018be97936268284adb36c3aae Mon Sep 17 00:00:00 2001
From: James Player
Date: Mon, 10 Mar 2025 09:21:20 +1300
Subject: [PATCH 036/212] fix(app): datatable global checkbox doesn't reflect
the selected state (#470)
---
app/portainer/helpers/strings.ts | 14 ++++----
app/react/common/string-utils.ts | 27 ++++++++++++++
.../components/datatables/Datatable.test.tsx | 36 +++++++++++++++++--
app/react/components/datatables/Datatable.tsx | 9 +++++
.../components/datatables/DatatableFooter.tsx | 4 ++-
.../datatables/SelectedRowsCount.tsx | 10 ++++--
.../components/datatables/select-column.tsx | 22 +++++++++---
.../components/form-components/Checkbox.tsx | 4 ++-
.../app-templates/AppTemplatesList.tsx | 1 +
.../ListView/CustomTemplatesList.tsx | 1 +
10 files changed, 110 insertions(+), 18 deletions(-)
create mode 100644 app/react/common/string-utils.ts
diff --git a/app/portainer/helpers/strings.ts b/app/portainer/helpers/strings.ts
index dc130b0e5..fb8d69bc6 100644
--- a/app/portainer/helpers/strings.ts
+++ b/app/portainer/helpers/strings.ts
@@ -1,7 +1,7 @@
-export function pluralize(val: number, word: string, plural = `${word}s`) {
- return [1, -1].includes(Number(val)) ? word : plural;
-}
-
-export function addPlural(value: number, word: string, plural = `${word}s`) {
- return `${value} ${pluralize(value, word, plural)}`;
-}
+// Re-exporting so we don't have to update one meeeeellion files that are already importing these
+// functions from here.
+export {
+ pluralize,
+ addPlural,
+ grammaticallyJoin,
+} from '@/react/common/string-utils';
diff --git a/app/react/common/string-utils.ts b/app/react/common/string-utils.ts
new file mode 100644
index 000000000..591d6de90
--- /dev/null
+++ b/app/react/common/string-utils.ts
@@ -0,0 +1,27 @@
+export function capitalize(s: string) {
+ return s.slice(0, 1).toUpperCase() + s.slice(1);
+}
+
+export function pluralize(val: number, word: string, plural = `${word}s`) {
+ return [1, -1].includes(Number(val)) ? word : plural;
+}
+
+export function addPlural(value: number, word: string, plural = `${word}s`) {
+ return `${value} ${pluralize(value, word, plural)}`;
+}
+
+/**
+ * Joins an array of strings into a grammatically correct sentence.
+ */
+export function grammaticallyJoin(
+ values: string[],
+ separator = ', ',
+ lastSeparator = ' and '
+) {
+ if (values.length === 0) return '';
+ if (values.length === 1) return values[0];
+
+ const allButLast = values.slice(0, -1);
+ const last = values[values.length - 1];
+ return `${allButLast.join(separator)}${lastSeparator}${last}`;
+}
diff --git a/app/react/components/datatables/Datatable.test.tsx b/app/react/components/datatables/Datatable.test.tsx
index 3b5a1e63f..5664a3ed6 100644
--- a/app/react/components/datatables/Datatable.test.tsx
+++ b/app/react/components/datatables/Datatable.test.tsx
@@ -1,4 +1,5 @@
import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import {
createColumnHelper,
@@ -170,7 +171,7 @@ describe('Datatable', () => {
fireEvent.click(selectAllCheckbox);
// Check if all rows on the page are selected
- expect(screen.getByText('2 item(s) selected')).toBeInTheDocument();
+ expect(screen.getByText('2 items selected')).toBeInTheDocument();
// Deselect
fireEvent.click(selectAllCheckbox);
@@ -192,13 +193,44 @@ describe('Datatable', () => {
fireEvent.click(selectAllCheckbox, { shiftKey: true });
// Check if all rows on the page are selected
- expect(screen.getByText('3 item(s) selected')).toBeInTheDocument();
+ expect(screen.getByText('3 items selected')).toBeInTheDocument();
// Deselect
fireEvent.click(selectAllCheckbox, { shiftKey: true });
const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
});
+
+ it('shows indeterminate state and correct footer text when hidden rows are selected', async () => {
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ // Select Jane
+ const checkboxes = screen.getAllByRole('checkbox');
+ await user.click(checkboxes[2]); // Select the second row
+
+ // Search for John (will hide selected Jane)
+ const searchInput = screen.getByPlaceholderText('Search...');
+ await user.type(searchInput, 'John');
+
+ // Check if the footer text is correct
+ expect(
+ await screen.findByText('1 item selected (1 hidden by filters)')
+ ).toBeInTheDocument();
+
+ // Check if the checkbox is indeterminate
+ const selectAllCheckbox: HTMLInputElement =
+ screen.getByLabelText('Select all rows');
+ expect(selectAllCheckbox.indeterminate).toBe(true);
+ expect(selectAllCheckbox.checked).toBe(false);
+ });
});
// Test the defaultGlobalFilterFn used in searches
diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx
index f845e221d..4ca608aa3 100644
--- a/app/react/components/datatables/Datatable.tsx
+++ b/app/react/components/datatables/Datatable.tsx
@@ -171,6 +171,14 @@ export function Datatable({
const selectedRowModel = tableInstance.getSelectedRowModel();
const selectedItems = selectedRowModel.rows.map((row) => row.original);
+ const filteredItems = tableInstance
+ .getFilteredRowModel()
+ .rows.map((row) => row.original);
+
+ const hiddenSelectedItems = useMemo(
+ () => _.difference(selectedItems, filteredItems),
+ [selectedItems, filteredItems]
+ );
return (
@@ -203,6 +211,7 @@ export function Datatable({
pageSize={tableState.pagination.pageSize}
pageCount={tableInstance.getPageCount()}
totalSelected={selectedItems.length}
+ totalHiddenSelected={hiddenSelectedItems.length}
/>
);
diff --git a/app/react/components/datatables/DatatableFooter.tsx b/app/react/components/datatables/DatatableFooter.tsx
index 61e7b4dbf..0907ae260 100644
--- a/app/react/components/datatables/DatatableFooter.tsx
+++ b/app/react/components/datatables/DatatableFooter.tsx
@@ -5,6 +5,7 @@ import { SelectedRowsCount } from './SelectedRowsCount';
interface Props {
totalSelected: number;
+ totalHiddenSelected: number;
pageSize: number;
page: number;
onPageChange(page: number): void;
@@ -14,6 +15,7 @@ interface Props {
export function DatatableFooter({
totalSelected,
+ totalHiddenSelected,
pageSize,
page,
onPageChange,
@@ -22,7 +24,7 @@ export function DatatableFooter({
}: Props) {
return (
-
+
{value} item(s) selected
+
+ {addPlural(value, 'item')} selected
+ {hidden !== 0 && ` (${hidden} hidden by filters)`}
+
) : null;
}
diff --git a/app/react/components/datatables/select-column.tsx b/app/react/components/datatables/select-column.tsx
index f20f4dd0b..46329588a 100644
--- a/app/react/components/datatables/select-column.tsx
+++ b/app/react/components/datatables/select-column.tsx
@@ -1,7 +1,19 @@
-import { ColumnDef, Row } from '@tanstack/react-table';
+import { ColumnDef, Row, Table } from '@tanstack/react-table';
import { Checkbox } from '@@/form-components/Checkbox';
+function allRowsSelected
(table: Table) {
+ return table.getCoreRowModel().rows.every((row) => row.getIsSelected());
+}
+
+function someRowsSelected(table: Table) {
+ return table.getCoreRowModel().rows.some((row) => row.getIsSelected());
+}
+
+function somePageRowsSelected(table: Table) {
+ return table.getRowModel().rows.some((row) => row.getIsSelected());
+}
+
export function createSelectColumn(dataCy: string): ColumnDef {
let lastSelectedId = '';
@@ -11,15 +23,15 @@ export function createSelectColumn(dataCy: string): ColumnDef {
{
// Select all rows if shift key is held down, otherwise only page rows
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey) {
- table.getToggleAllRowsSelectedHandler()(e);
+ table.toggleAllRowsSelected();
return;
}
- table.getToggleAllPageRowsSelectedHandler()(e);
+ table.toggleAllPageRowsSelected(!somePageRowsSelected(table));
}}
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
onClick={(e) => {
diff --git a/app/react/components/form-components/Checkbox.tsx b/app/react/components/form-components/Checkbox.tsx
index 3e01222b8..03f199de1 100644
--- a/app/react/components/form-components/Checkbox.tsx
+++ b/app/react/components/form-components/Checkbox.tsx
@@ -42,6 +42,8 @@ export const Checkbox = forwardRef(
resolvedRef = defaultRef;
}
+ // Need to check this on every render as the browser will always set the element's
+ // indeterminate state to false when the checkbox is clicked, even if the indeterminate prop hasn't changed
useEffect(() => {
if (resolvedRef === null || resolvedRef.current === null) {
return;
@@ -50,7 +52,7 @@ export const Checkbox = forwardRef(
if (typeof indeterminate !== 'undefined') {
resolvedRef.current.indeterminate = indeterminate;
}
- }, [resolvedRef, indeterminate]);
+ });
return (
diff --git a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx
index 00e4e54c5..35e446e8a 100644
--- a/app/react/portainer/templates/app-templates/AppTemplatesList.tsx
+++ b/app/react/portainer/templates/app-templates/AppTemplatesList.tsx
@@ -110,6 +110,7 @@ export function AppTemplatesList({
pageSize={listState.pageSize}
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
totalSelected={0}
+ totalHiddenSelected={0}
/>
);
diff --git a/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesList.tsx b/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesList.tsx
index a0bb23720..18b1bdaa9 100644
--- a/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesList.tsx
+++ b/app/react/portainer/templates/custom-templates/ListView/CustomTemplatesList.tsx
@@ -86,6 +86,7 @@ export function CustomTemplatesList({
pageSize={listState.pageSize}
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
totalSelected={0}
+ totalHiddenSelected={0}
/>
);
From 28b222fffacf4a030aedadd3496d5ef91a431505 Mon Sep 17 00:00:00 2001
From: James Player
Date: Wed, 12 Mar 2025 10:34:07 +1300
Subject: [PATCH 037/212] fix(app): Make sure empty tables don't have select
all rows checkbox checked (#489)
---
app/react/components/datatables/Datatable.test.tsx | 3 +++
app/react/components/datatables/select-column.tsx | 3 ++-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/app/react/components/datatables/Datatable.test.tsx b/app/react/components/datatables/Datatable.test.tsx
index 5664a3ed6..67ff85f70 100644
--- a/app/react/components/datatables/Datatable.test.tsx
+++ b/app/react/components/datatables/Datatable.test.tsx
@@ -155,6 +155,9 @@ describe('Datatable', () => {
);
expect(screen.getByText('No data available')).toBeInTheDocument();
+ const selectAllCheckbox: HTMLInputElement =
+ screen.getByLabelText('Select all rows');
+ expect(selectAllCheckbox.checked).toBe(false);
});
it('selects/deselects only page rows when select all is clicked', () => {
diff --git a/app/react/components/datatables/select-column.tsx b/app/react/components/datatables/select-column.tsx
index 46329588a..2e4a04272 100644
--- a/app/react/components/datatables/select-column.tsx
+++ b/app/react/components/datatables/select-column.tsx
@@ -3,7 +3,8 @@ import { ColumnDef, Row, Table } from '@tanstack/react-table';
import { Checkbox } from '@@/form-components/Checkbox';
function allRowsSelected(table: Table) {
- return table.getCoreRowModel().rows.every((row) => row.getIsSelected());
+ const { rows } = table.getCoreRowModel();
+ return rows.length > 0 && rows.every((row) => row.getIsSelected());
}
function someRowsSelected(table: Table) {
From 798fa2396a892cb95737cdab62edea51f0ae206a Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Wed, 12 Mar 2025 22:34:00 +1300
Subject: [PATCH 038/212] feat: kubernets service - display external hostname
(#486)
---
api/http/models/kubernetes/services.go | 4 +-
api/kubernetes/cli/service.go | 6 +--
.../columns/externalHost.tsx | 38 +++++++++++++++++++
.../ServicesDatatable/columns/index.tsx | 2 +
4 files changed, 45 insertions(+), 5 deletions(-)
create mode 100644 app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/externalHost.tsx
diff --git a/api/http/models/kubernetes/services.go b/api/http/models/kubernetes/services.go
index 04ac27bf3..bcbddf3ae 100644
--- a/api/http/models/kubernetes/services.go
+++ b/api/http/models/kubernetes/services.go
@@ -35,8 +35,8 @@ type (
}
K8sServiceIngress struct {
- IP string `json:"IP"`
- Host string `json:"Host"`
+ IP string `json:"IP"`
+ Hostname string `json:"Hostname"`
}
// K8sServiceDeleteRequests is a mapping of namespace names to a slice of
diff --git a/api/kubernetes/cli/service.go b/api/kubernetes/cli/service.go
index 8a7ef03ab..a70c5ca3e 100644
--- a/api/kubernetes/cli/service.go
+++ b/api/kubernetes/cli/service.go
@@ -81,8 +81,8 @@ func parseService(service corev1.Service) models.K8sServiceInfo {
ingressStatus := make([]models.K8sServiceIngress, 0)
for _, status := range service.Status.LoadBalancer.Ingress {
ingressStatus = append(ingressStatus, models.K8sServiceIngress{
- IP: status.IP,
- Host: status.Hostname,
+ IP: status.IP,
+ Hostname: status.Hostname,
})
}
@@ -130,7 +130,7 @@ func (kcl *KubeClient) convertToK8sService(info models.K8sServiceInfo) corev1.Se
for _, i := range info.IngressStatus {
service.Status.LoadBalancer.Ingress = append(
service.Status.LoadBalancer.Ingress,
- corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Host},
+ corev1.LoadBalancerIngress{IP: i.IP, Hostname: i.Hostname},
)
}
diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/externalHost.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/externalHost.tsx
new file mode 100644
index 000000000..7a0e8c386
--- /dev/null
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/externalHost.tsx
@@ -0,0 +1,38 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { ServiceRowData } from '../types';
+
+import { columnHelper } from './helper';
+
+export const externalHost = columnHelper.accessor(
+ (row) => {
+ if (row.Type === 'ExternalName') {
+ return row.ExternalName || '-';
+ }
+
+ return (
+ row.IngressStatus?.map((status) => status.Hostname)
+ .filter(Boolean)
+ .join(' ,') || '-'
+ );
+ },
+ {
+ header: 'External Host',
+ id: 'externalHost',
+ cell: Cell,
+ }
+);
+
+function Cell({ row }: CellContext) {
+ if (row.original.Type === 'ExternalName') {
+ return {row.original.ExternalName || '-'}
;
+ }
+
+ return (
+
+ {row.original.IngressStatus?.map((status) => status.Hostname)
+ .filter(Boolean)
+ .join(' ,') || '-'}
+
+ );
+}
diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/index.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/index.tsx
index e061b3e70..1cc66e106 100644
--- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/index.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/index.tsx
@@ -3,6 +3,7 @@ import { type } from './type';
import { namespace } from './namespace';
import { ports } from './ports';
import { clusterIP } from './clusterIP';
+import { externalHost } from './externalHost';
import { externalIP } from './externalIP';
import { targetPorts } from './targetPorts';
import { application } from './application';
@@ -16,6 +17,7 @@ export const columns = [
ports,
targetPorts,
clusterIP,
+ externalHost,
externalIP,
created,
];
From 0d25f3f43084038893eac9ad4deffa2e91c481f4 Mon Sep 17 00:00:00 2001
From: LP B
Date: Wed, 12 Mar 2025 14:00:31 +0100
Subject: [PATCH 039/212] fix(app): restore gitops update options (#419)
---
app/portainer/components/forms/git-form/index.ts | 2 +-
app/portainer/react/components/git-form.ts | 1 +
app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx | 1 +
app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx | 2 ++
app/react/portainer/gitops/GitForm.tsx | 5 +++--
go.mod | 2 +-
6 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/app/portainer/components/forms/git-form/index.ts b/app/portainer/components/forms/git-form/index.ts
index e478b62ca..26793f584 100644
--- a/app/portainer/components/forms/git-form/index.ts
+++ b/app/portainer/components/forms/git-form/index.ts
@@ -7,7 +7,7 @@ import { gitFormRefField } from './git-form-ref-field';
export const gitFormModule = angular
.module('portainer.app.components.git-form', [])
- .component('gitForm', gitForm)
+ .component('gitForm', gitForm) // kube deploy + docker stack create
.component('gitFormAuthFieldset', gitFormAuthFieldset)
.component('gitFormAutoUpdateFieldset', gitFormAutoUpdate)
.component('gitFormRefField', gitFormRefField).name;
diff --git a/app/portainer/react/components/git-form.ts b/app/portainer/react/components/git-form.ts
index bc74e3715..af0c7d460 100644
--- a/app/portainer/react/components/git-form.ts
+++ b/app/portainer/react/components/git-form.ts
@@ -29,6 +29,7 @@ export const gitFormModule = angular
'webhookId',
'webhooksDocs',
'createdFromCustomTemplateId',
+ 'isAutoUpdateVisible',
])
)
.component(
diff --git a/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx b/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx
index 2f4c29f83..608e97500 100644
--- a/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx
+++ b/app/react/edge/edge-stacks/CreateView/DockerComposeForm.tsx
@@ -139,6 +139,7 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
}
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
+ isAutoUpdateVisible={isBE}
/>
{isBE && (
diff --git a/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx b/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx
index 0428f5859..1c516d86e 100644
--- a/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx
+++ b/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx
@@ -4,6 +4,7 @@ import { FormikErrors } from 'formik';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import { GitFormModel } from '@/react/portainer/gitops/types';
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
+import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { BoxSelector } from '@@/BoxSelector';
import { WebEditorForm } from '@@/WebEditorForm';
@@ -109,6 +110,7 @@ export function KubeManifestForm({
}
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
+ isAutoUpdateVisible={isBE}
/>
)}
>
diff --git a/app/react/portainer/gitops/GitForm.tsx b/app/react/portainer/gitops/GitForm.tsx
index 834de6e82..89cb062bf 100644
--- a/app/react/portainer/gitops/GitForm.tsx
+++ b/app/react/portainer/gitops/GitForm.tsx
@@ -7,7 +7,6 @@ import { RefField } from '@/react/portainer/gitops/RefField';
import { GitFormUrlField } from '@/react/portainer/gitops/GitFormUrlField';
import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types';
import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay';
-import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { FormSection } from '@@/form-components/FormSection';
import { validateForm } from '@@/form-components/validate-form';
@@ -35,6 +34,7 @@ interface Props {
webhookId?: string;
webhooksDocs?: string;
createdFromCustomTemplateId?: number;
+ isAutoUpdateVisible?: boolean;
}
export function GitForm({
@@ -51,6 +51,7 @@ export function GitForm({
webhookId,
webhooksDocs,
createdFromCustomTemplateId,
+ isAutoUpdateVisible = true,
}: Props) {
const [value, setValue] = useState(initialValue); // TODO: remove this state when form is not inside angularjs
@@ -117,7 +118,7 @@ export function GitForm({
/>
)}
- {isBE && value.AutoUpdate && (
+ {isAutoUpdateVisible && value.AutoUpdate && (
Date: Thu, 13 Mar 2025 12:20:16 +1300
Subject: [PATCH 040/212] refactor(helm): helm binary to sdk refactor [r8s-229]
(#463)
Co-authored-by: stevensbkang
---
api/cmd/portainer/main.go | 7 +-
api/database/boltdb/json_test.go | 2 +-
.../test_data/output_24_to_latest.json | 2 +-
api/http/handler/helm/handler.go | 14 +-
api/http/handler/helm/helm_delete_test.go | 4 +-
api/http/handler/helm/helm_install.go | 14 +-
api/http/handler/helm/helm_install_test.go | 6 +-
api/http/handler/helm/helm_list_test.go | 4 +-
.../handler/helm/helm_repo_search_test.go | 6 +-
api/http/handler/helm/helm_show_test.go | 6 +-
api/http/handler/settings/settings_update.go | 2 +-
api/http/server.go | 4 +-
api/portainer.go | 10 +-
.../HelmApplicationView.test.tsx | 14 +-
.../HelmApplicationView.tsx | 55 +--
.../HelmApplicationView/HelmDetailsWidget.tsx | 67 ++++
build/build_binary.sh | 2 -
build/download_binaries.sh | 10 -
build/download_helm_binary.sh | 21 --
build/linux/Dockerfile | 1 -
build/linux/alpine.Dockerfile | 1 -
build/windows/Dockerfile | 1 -
go.mod | 114 ++++--
go.sum | 297 ++++++++++-----
pkg/build/info.go | 4 -
pkg/libhelm/binary/get.go | 29 --
pkg/libhelm/binary/helm_package.go | 62 ----
pkg/libhelm/binary/install.go | 48 ---
pkg/libhelm/binary/install_test.go | 118 ------
pkg/libhelm/binary/list.go | 38 --
pkg/libhelm/binary/search_repo.go | 118 ------
pkg/libhelm/binary/search_repo_test.go | 50 ---
pkg/libhelm/binary/show.go | 29 --
pkg/libhelm/binary/uninstall.go | 29 --
pkg/libhelm/manager.go | 19 +-
pkg/libhelm/options/cluster_access.go | 3 +
pkg/libhelm/sdk/client.go | 165 ++++++++
pkg/libhelm/sdk/client_test.go | 156 ++++++++
pkg/libhelm/sdk/install.go | 210 +++++++++++
pkg/libhelm/sdk/install_test.go | 190 ++++++++++
pkg/libhelm/sdk/list.go | 108 ++++++
pkg/libhelm/sdk/list_test.go | 72 ++++
pkg/libhelm/sdk/manager.go | 23 ++
pkg/libhelm/sdk/manager_test.go | 29 ++
pkg/libhelm/sdk/search_repo.go | 351 ++++++++++++++++++
pkg/libhelm/sdk/search_repo_test.go | 84 +++++
pkg/libhelm/sdk/show.go | 131 +++++++
pkg/libhelm/sdk/show_test.go | 102 +++++
pkg/libhelm/sdk/uninstall.go | 70 ++++
pkg/libhelm/sdk/uninstall_test.go | 65 ++++
pkg/libhelm/sdk/values.go | 42 +++
.../{libhelmtest => test}/integration.go | 2 +-
pkg/libhelm/{binary => }/test/mock.go | 6 +-
pkg/libhelm/{helm.go => types/types.go} | 14 +-
pkg/libhelm/validate_repo.go | 4 +
pkg/libhelm/validate_repo_test.go | 6 +-
56 files changed, 2222 insertions(+), 819 deletions(-)
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmDetailsWidget.tsx
delete mode 100755 build/download_helm_binary.sh
delete mode 100644 pkg/libhelm/binary/get.go
delete mode 100644 pkg/libhelm/binary/helm_package.go
delete mode 100644 pkg/libhelm/binary/install.go
delete mode 100644 pkg/libhelm/binary/install_test.go
delete mode 100644 pkg/libhelm/binary/list.go
delete mode 100644 pkg/libhelm/binary/search_repo.go
delete mode 100644 pkg/libhelm/binary/search_repo_test.go
delete mode 100644 pkg/libhelm/binary/show.go
delete mode 100644 pkg/libhelm/binary/uninstall.go
create mode 100644 pkg/libhelm/sdk/client.go
create mode 100644 pkg/libhelm/sdk/client_test.go
create mode 100644 pkg/libhelm/sdk/install.go
create mode 100644 pkg/libhelm/sdk/install_test.go
create mode 100644 pkg/libhelm/sdk/list.go
create mode 100644 pkg/libhelm/sdk/list_test.go
create mode 100644 pkg/libhelm/sdk/manager.go
create mode 100644 pkg/libhelm/sdk/manager_test.go
create mode 100644 pkg/libhelm/sdk/search_repo.go
create mode 100644 pkg/libhelm/sdk/search_repo_test.go
create mode 100644 pkg/libhelm/sdk/show.go
create mode 100644 pkg/libhelm/sdk/show_test.go
create mode 100644 pkg/libhelm/sdk/uninstall.go
create mode 100644 pkg/libhelm/sdk/uninstall_test.go
create mode 100644 pkg/libhelm/sdk/values.go
rename pkg/libhelm/{libhelmtest => test}/integration.go (93%)
rename pkg/libhelm/{binary => }/test/mock.go (95%)
rename pkg/libhelm/{helm.go => types/types.go} (72%)
diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go
index 88e21f17b..068f31565 100644
--- a/api/cmd/portainer/main.go
+++ b/api/cmd/portainer/main.go
@@ -49,6 +49,7 @@ import (
"github.com/portainer/portainer/pkg/build"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/portainer/portainer/pkg/libhelm"
+ libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/gofrs/uuid"
@@ -169,8 +170,8 @@ func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheMan
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
}
-func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) {
- return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath})
+func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
+ return libhelm.NewHelmPackageManager()
}
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
@@ -437,7 +438,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
- helmPackageManager, err := initHelmPackageManager(*flags.Assets)
+ helmPackageManager, err := initHelmPackageManager()
if err != nil {
log.Fatal().Err(err).Msg("failed initializing helm package manager")
}
diff --git a/api/database/boltdb/json_test.go b/api/database/boltdb/json_test.go
index ba0863efd..577aa2cfd 100644
--- a/api/database/boltdb/json_test.go
+++ b/api/database/boltdb/json_test.go
@@ -10,7 +10,7 @@ import (
)
const (
- jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://kubernetes.github.io/ingress-nginx","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
+ jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
passphrase = "my secret key"
)
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index 8c5dc01e7..89d781169 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -605,7 +605,7 @@
"GlobalDeploymentOptions": {
"hideStacksFunctionality": false
},
- "HelmRepositoryURL": "",
+ "HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
"InternalAuthSettings": {
"RequiredPasswordLength": 12
},
diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go
index fb941940b..b776fe168 100644
--- a/api/http/handler/helm/handler.go
+++ b/api/http/handler/helm/handler.go
@@ -1,6 +1,7 @@
package helm
import (
+ "fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -8,8 +9,8 @@ import (
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes"
- "github.com/portainer/portainer/pkg/libhelm"
"github.com/portainer/portainer/pkg/libhelm/options"
+ libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
@@ -23,11 +24,11 @@ type Handler struct {
jwtService portainer.JWTService
kubeClusterAccessService kubernetes.KubeClusterAccessService
kubernetesDeployer portainer.KubernetesDeployer
- helmPackageManager libhelm.HelmPackageManager
+ helmPackageManager libhelmtypes.HelmPackageManager
}
// NewHandler creates a handler to manage endpoint group operations.
-func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelm.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
+func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, jwtService portainer.JWTService, kubernetesDeployer portainer.KubernetesDeployer, helmPackageManager libhelmtypes.HelmPackageManager, kubeClusterAccessService kubernetes.KubeClusterAccessService) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
@@ -57,7 +58,7 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
}
// NewTemplateHandler creates a template handler to manage environment(endpoint) group operations.
-func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelm.HelmPackageManager) *Handler {
+func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libhelmtypes.HelmPackageManager) *Handler {
h := &Handler{
Router: mux.NewRouter(),
helmPackageManager: helmPackageManager,
@@ -78,7 +79,7 @@ func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libh
// getHelmClusterAccess obtains the core k8s cluster access details from request.
// The cluster access includes the cluster server url, the user's bearer token and the tls certificate.
-// The cluster access is passed in as kube config CLI params to helm binary.
+// The cluster access is passed in as kube config CLI params to helm.
func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.KubernetesClusterAccess, *httperror.HandlerError) {
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
@@ -107,6 +108,9 @@ func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.Kubernet
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true)
return &options.KubernetesClusterAccess{
+ ClusterName: fmt.Sprintf("%s-%s", "portainer-cluster", endpoint.Name),
+ ContextName: fmt.Sprintf("%s-%s", "portainer-ctx", endpoint.Name),
+ UserName: fmt.Sprintf("%s-%s", "portainer-sa-user", tokenData.Username),
ClusterServerURL: kubeConfigInternal.ClusterServerURL,
CertificateAuthorityFile: kubeConfigInternal.CertificateAuthorityFile,
AuthToken: bearerToken,
diff --git a/api/http/handler/helm/helm_delete_test.go b/api/http/handler/helm/helm_delete_test.go
index 7d676fab9..ed77abdd2 100644
--- a/api/http/handler/helm/helm_delete_test.go
+++ b/api/http/handler/helm/helm_delete_test.go
@@ -13,8 +13,8 @@ import (
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
- "github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
@@ -34,7 +34,7 @@ func Test_helmDelete(t *testing.T) {
is.NoError(err, "Error initiating jwt service")
kubernetesDeployer := exectest.NewKubernetesDeployer()
- helmPackageManager := test.NewMockHelmBinaryPackageManager("")
+ helmPackageManager := test.NewMockHelmPackageManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go
index dd1365f82..fffdf21bb 100644
--- a/api/http/handler/helm/helm_install.go
+++ b/api/http/handler/helm/helm_install.go
@@ -99,15 +99,11 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
}
installOpts := options.InstallOptions{
- Name: p.Name,
- Chart: p.Chart,
- Namespace: p.Namespace,
- Repo: p.Repo,
- KubernetesClusterAccess: &options.KubernetesClusterAccess{
- ClusterServerURL: clusterAccess.ClusterServerURL,
- CertificateAuthorityFile: clusterAccess.CertificateAuthorityFile,
- AuthToken: clusterAccess.AuthToken,
- },
+ Name: p.Name,
+ Chart: p.Chart,
+ Namespace: p.Namespace,
+ Repo: p.Repo,
+ KubernetesClusterAccess: clusterAccess,
}
if p.Values != "" {
diff --git a/api/http/handler/helm/helm_install_test.go b/api/http/handler/helm/helm_install_test.go
index 0b87d3a23..ab2da85e6 100644
--- a/api/http/handler/helm/helm_install_test.go
+++ b/api/http/handler/helm/helm_install_test.go
@@ -15,9 +15,9 @@ import (
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
- "github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/portainer/portainer/pkg/libhelm/test"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
@@ -38,14 +38,14 @@ func Test_helmInstall(t *testing.T) {
is.NoError(err, "Error initiating jwt service")
kubernetesDeployer := exectest.NewKubernetesDeployer()
- helmPackageManager := test.NewMockHelmBinaryPackageManager("")
+ helmPackageManager := test.NewMockHelmPackageManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
is.NotNil(h, "Handler should not fail")
// Install a single chart. We expect to get these values back
- options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://kubernetes.github.io/ingress-nginx"}
+ options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://charts.bitnami.com/bitnami"}
optdata, err := json.Marshal(options)
is.NoError(err)
diff --git a/api/http/handler/helm/helm_list_test.go b/api/http/handler/helm/helm_list_test.go
index 8ef51b324..b8dd40354 100644
--- a/api/http/handler/helm/helm_list_test.go
+++ b/api/http/handler/helm/helm_list_test.go
@@ -14,9 +14,9 @@ import (
helper "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
- "github.com/portainer/portainer/pkg/libhelm/binary/test"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/portainer/portainer/pkg/libhelm/test"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
@@ -37,7 +37,7 @@ func Test_helmList(t *testing.T) {
is.NoError(err, "Error initialising jwt service")
kubernetesDeployer := exectest.NewKubernetesDeployer()
- helmPackageManager := test.NewMockHelmBinaryPackageManager("")
+ helmPackageManager := test.NewMockHelmPackageManager()
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
diff --git a/api/http/handler/helm/helm_repo_search_test.go b/api/http/handler/helm/helm_repo_search_test.go
index a556908b9..63dac43b5 100644
--- a/api/http/handler/helm/helm_repo_search_test.go
+++ b/api/http/handler/helm/helm_repo_search_test.go
@@ -8,19 +8,19 @@ import (
"testing"
helper "github.com/portainer/portainer/api/internal/testhelpers"
- "github.com/portainer/portainer/pkg/libhelm/binary/test"
+ "github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func Test_helmRepoSearch(t *testing.T) {
is := assert.New(t)
- helmPackageManager := test.NewMockHelmBinaryPackageManager("")
+ helmPackageManager := test.NewMockHelmPackageManager()
h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager)
assert.NotNil(t, h, "Handler should not fail")
- repos := []string{"https://kubernetes.github.io/ingress-nginx", "https://portainer.github.io/k8s"}
+ repos := []string{"https://charts.bitnami.com/bitnami", "https://portainer.github.io/k8s"}
for _, repo := range repos {
t.Run(repo, func(t *testing.T) {
diff --git a/api/http/handler/helm/helm_show_test.go b/api/http/handler/helm/helm_show_test.go
index fed59388d..385843b65 100644
--- a/api/http/handler/helm/helm_show_test.go
+++ b/api/http/handler/helm/helm_show_test.go
@@ -9,14 +9,14 @@ import (
"testing"
helper "github.com/portainer/portainer/api/internal/testhelpers"
- "github.com/portainer/portainer/pkg/libhelm/binary/test"
+ "github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func Test_helmShow(t *testing.T) {
is := assert.New(t)
- helmPackageManager := test.NewMockHelmBinaryPackageManager("")
+ helmPackageManager := test.NewMockHelmPackageManager()
h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager)
is.NotNil(h, "Handler should not fail")
@@ -31,7 +31,7 @@ func Test_helmShow(t *testing.T) {
t.Run(cmd, func(t *testing.T) {
is.NotNil(h, "Handler should not fail")
- repoUrlEncoded := url.QueryEscape("https://kubernetes.github.io/ingress-nginx")
+ repoUrlEncoded := url.QueryEscape("https://charts.bitnami.com/bitnami")
chart := "nginx"
req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil)
rr := httptest.NewRecorder()
diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go
index 895da489c..0b36dbc62 100644
--- a/api/http/handler/settings/settings_update.go
+++ b/api/http/handler/settings/settings_update.go
@@ -46,7 +46,7 @@ type settingsUpdatePayload struct {
// Whether telemetry is enabled
EnableTelemetry *bool `example:"false"`
// Helm repository URL
- HelmRepositoryURL *string `example:"https://kubernetes.github.io/ingress-nginx"`
+ HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"`
// Kubectl Shell Image
KubectlShellImage *string `example:"portainer/kubectl-shell:latest"`
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
diff --git a/api/http/server.go b/api/http/server.go
index c5bb0d40f..3dcc84d2d 100644
--- a/api/http/server.go
+++ b/api/http/server.go
@@ -67,7 +67,7 @@ import (
"github.com/portainer/portainer/api/platform"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/deployments"
- "github.com/portainer/portainer/pkg/libhelm"
+ libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
"github.com/rs/zerolog/log"
)
@@ -103,7 +103,7 @@ type Server struct {
DockerClientFactory *dockerclient.ClientFactory
KubernetesClientFactory *cli.ClientFactory
KubernetesDeployer portainer.KubernetesDeployer
- HelmPackageManager libhelm.HelmPackageManager
+ HelmPackageManager libhelmtypes.HelmPackageManager
Scheduler *scheduler.Scheduler
ShutdownCtx context.Context
ShutdownTrigger context.CancelFunc
diff --git a/api/portainer.go b/api/portainer.go
index 990747813..9e1451633 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -588,7 +588,7 @@ type (
// User identifier
UserID UserID `json:"UserId" example:"1"`
// Helm repository URL
- URL string `json:"URL" example:"https://kubernetes.github.io/ingress-nginx"`
+ URL string `json:"URL" example:"https://charts.bitnami.com/bitnami"`
}
// QuayRegistryData represents data required for Quay registry to work
@@ -984,8 +984,8 @@ type (
KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"`
// Whether telemetry is enabled
EnableTelemetry bool `json:"EnableTelemetry" example:"false"`
- // Helm repository URL, defaults to ""
- HelmRepositoryURL string `json:"HelmRepositoryURL"`
+ // Helm repository URL, defaults to "https://charts.bitnami.com/bitnami"
+ HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"`
// KubectlImage, defaults to portainer/kubectl-shell
KubectlShellImage string `json:"KubectlShellImage" example:"portainer/kubectl-shell"`
// TrustOnFirstConnect makes Portainer accepting edge agent connection by default
@@ -1673,8 +1673,8 @@ const (
DefaultEdgeAgentCheckinIntervalInSeconds = 5
// DefaultTemplatesURL represents the URL to the official templates supported by Portainer
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/v3/templates.json"
- // DefaultHelmrepositoryURL set to empty string until oci support is added
- DefaultHelmRepositoryURL = ""
+ // DefaultHelmrepositoryURL represents the URL to the official templates supported by Bitnami
+ DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
DefaultUserSessionTimeout = "8h"
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
index 296b17ff5..63b0468eb 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
@@ -62,19 +62,19 @@ describe('HelmApplicationView', () => {
expect(await screen.findByText('Helm details')).toBeInTheDocument();
// Check for the release details
- expect(screen.getByText('Release')).toBeInTheDocument();
+ expect(await screen.findByText('Release')).toBeInTheDocument();
// Check for the table content
- expect(screen.getByText('Name')).toBeInTheDocument();
- expect(screen.getByText('Chart')).toBeInTheDocument();
- expect(screen.getByText('App version')).toBeInTheDocument();
+ expect(await screen.findByText('Name')).toBeInTheDocument();
+ expect(await screen.findByText('Chart')).toBeInTheDocument();
+ expect(await screen.findByText('App version')).toBeInTheDocument();
// Check for the actual values
- expect(screen.getByTestId('k8sAppDetail-appName')).toHaveTextContent(
+ expect(await screen.findByTestId('k8sAppDetail-appName')).toHaveTextContent(
'test-release'
);
- expect(screen.getByText('test-chart-1.0.0')).toBeInTheDocument();
- expect(screen.getByText('1.0.0')).toBeInTheDocument();
+ expect(await screen.findByText('test-chart-1.0.0')).toBeInTheDocument();
+ expect(await screen.findByText('1.0.0')).toBeInTheDocument();
});
it('should display error message when API request fails', async () => {
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
index f50b59154..fa02574cc 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
@@ -1,37 +1,13 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { PageHeader } from '@/react/components/PageHeader';
-import { Widget, WidgetBody, WidgetTitle } from '@/react/components/Widget';
-import helm from '@/assets/ico/vendor/helm.svg?c';
-import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
-import { ViewLoading } from '@@/ViewLoading';
-import { Alert } from '@@/Alert';
-
-import { useHelmRelease } from './queries/useHelmRelease';
+import { HelmDetailsWidget } from './HelmDetailsWidget';
export function HelmApplicationView() {
const { params } = useCurrentStateAndParams();
- const environmentId = useEnvironmentId();
- const name = params.name as string;
- const namespace = params.namespace as string;
-
- const {
- data: release,
- isLoading,
- error,
- } = useHelmRelease(environmentId, name, namespace);
-
- if (isLoading) {
- return ;
- }
-
- if (error || !release) {
- return (
-
- );
- }
+ const { name, namespace } = params;
return (
<>
@@ -46,32 +22,7 @@ export function HelmApplicationView() {
-
-
-
-
-
-
- Name
-
- {release.name}
-
-
-
- Chart
- {release.chart}
-
-
- App version
- {release.app_version}
-
-
-
-
-
+
>
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmDetailsWidget.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmDetailsWidget.tsx
new file mode 100644
index 000000000..97703e0ff
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmDetailsWidget.tsx
@@ -0,0 +1,67 @@
+import {
+ Loading,
+ Widget,
+ WidgetBody,
+ WidgetTitle,
+} from '@/react/components/Widget';
+import helm from '@/assets/ico/vendor/helm.svg?c';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { Alert } from '@@/Alert';
+
+import { useHelmRelease } from './queries/useHelmRelease';
+
+interface HelmDetailsWidgetProps {
+ name: string;
+ namespace: string;
+}
+
+export function HelmDetailsWidget({ name, namespace }: HelmDetailsWidgetProps) {
+ const environmentId = useEnvironmentId();
+
+ const {
+ data: release,
+ isInitialLoading,
+ isError,
+ } = useHelmRelease(environmentId, name, namespace);
+
+ return (
+
+
+
+ {isInitialLoading && }
+
+ {isError && (
+
+ )}
+
+ {!isInitialLoading && !isError && release && (
+
+
+
+ Name
+
+ {release.name}
+
+
+
+ Chart
+ {release.chart}
+
+
+ App version
+ {release.app_version}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/build/build_binary.sh b/build/build_binary.sh
index 6f87cd939..9051c76d2 100755
--- a/build/build_binary.sh
+++ b/build/build_binary.sh
@@ -23,7 +23,6 @@ GIT_COMMIT_HASH=${GIT_COMMIT_HASH:-$(git rev-parse --short HEAD)}
# populate dependencies versions
DOCKER_VERSION=$(jq -r '.docker' < "${BINARY_VERSION_FILE}")
-HELM_VERSION=$(jq -r '.helm' < "${BINARY_VERSION_FILE}")
KUBECTL_VERSION=$(jq -r '.kubectl' < "${BINARY_VERSION_FILE}")
COMPOSE_VERSION=$(go list -m -f '{{.Version}}' github.com/docker/compose/v2)
@@ -52,7 +51,6 @@ ldflags="-s -X 'github.com/portainer/liblicense.LicenseServerBaseURL=https://api
-X 'github.com/portainer/portainer/pkg/build.GoVersion=${GO_VERSION}' \
-X 'github.com/portainer/portainer/pkg/build.DepComposeVersion=${COMPOSE_VERSION}' \
-X 'github.com/portainer/portainer/pkg/build.DepDockerVersion=${DOCKER_VERSION}' \
--X 'github.com/portainer/portainer/pkg/build.DepHelmVersion=${HELM_VERSION}' \
-X 'github.com/portainer/portainer/pkg/build.DepKubectlVersion=${KUBECTL_VERSION}'"
echo "$ldflags"
diff --git a/build/download_binaries.sh b/build/download_binaries.sh
index 75e571a20..05c2c780b 100755
--- a/build/download_binaries.sh
+++ b/build/download_binaries.sh
@@ -17,12 +17,10 @@ echo "Checking and downloading binaries for docker ${dockerVersion}, helm ${helm
# Determine the binary file names based on the platform
dockerBinary="dist/docker"
-helmBinary="dist/helm"
kubectlBinary="dist/kubectl"
if [ "$PLATFORM" == "windows" ]; then
dockerBinary="dist/docker.exe"
- helmBinary="dist/helm.exe"
kubectlBinary="dist/kubectl.exe"
fi
@@ -34,14 +32,6 @@ else
echo "Docker binary already exists, skipping download."
fi
-# Check and download helm binary
-if [ ! -f "$helmBinary" ]; then
- echo "Downloading helm binary..."
- /usr/bin/env bash ./build/download_helm_binary.sh "$PLATFORM" "$ARCH" "$helmVersion"
-else
- echo "Helm binary already exists, skipping download."
-fi
-
# Check and download kubectl binary
if [ ! -f "$kubectlBinary" ]; then
echo "Downloading kubectl binary..."
diff --git a/build/download_helm_binary.sh b/build/download_helm_binary.sh
deleted file mode 100755
index ae3de93e7..000000000
--- a/build/download_helm_binary.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-if [[ $# -ne 3 ]]; then
- echo "Illegal number of parameters" >&2
- exit 1
-fi
-
-PLATFORM=$1
-ARCH=$2
-HELM_VERSION=$3
-HELM_DIST="helm-$HELM_VERSION-$PLATFORM-$ARCH"
-
-
-if [[ ${PLATFORM} == "windows" ]]; then
- wget --tries=3 --waitretry=30 --quiet -O tmp.zip "https://get.helm.sh/${HELM_DIST}.zip" && unzip -o -j tmp.zip "${PLATFORM}-${ARCH}/helm.exe" -d dist && rm -f tmp.zip
-else
- wget -qO- "https://get.helm.sh/${HELM_DIST}.tar.gz" | tar -x -z --strip-components 1 "${PLATFORM}-${ARCH}/helm"
- mv "helm" "dist/helm"
- chmod +x "dist/helm"
-fi
diff --git a/build/linux/Dockerfile b/build/linux/Dockerfile
index f7a921220..eeded41c1 100644
--- a/build/linux/Dockerfile
+++ b/build/linux/Dockerfile
@@ -11,7 +11,6 @@ LABEL org.opencontainers.image.title="Portainer" \
com.docker.extension.additional-urls="[{\"title\":\"Website\",\"url\":\"https://www.portainer.io?utm_campaign=DockerCon&utm_source=DockerDesktop\"},{\"title\":\"Documentation\",\"url\":\"https://docs.portainer.io\"},{\"title\":\"Support\",\"url\":\"https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA\"}]"
COPY dist/docker /
-COPY dist/helm /
COPY dist/kubectl /
COPY dist/mustache-templates /mustache-templates/
COPY dist/portainer /
diff --git a/build/linux/alpine.Dockerfile b/build/linux/alpine.Dockerfile
index 0f7b29145..e2ced7d3e 100644
--- a/build/linux/alpine.Dockerfile
+++ b/build/linux/alpine.Dockerfile
@@ -11,7 +11,6 @@ LABEL org.opencontainers.image.title="Portainer" \
com.docker.extension.additional-urls="[{\"title\":\"Website\",\"url\":\"https://www.portainer.io?utm_campaign=DockerCon&utm_source=DockerDesktop\"},{\"title\":\"Documentation\",\"url\":\"https://docs.portainer.io\"},{\"title\":\"Support\",\"url\":\"https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA\"}]"
COPY dist/docker /
-COPY dist/helm /
COPY dist/kubectl /
COPY dist/mustache-templates /mustache-templates/
COPY dist/portainer /
diff --git a/build/windows/Dockerfile b/build/windows/Dockerfile
index bf9f0f41b..324e68f04 100644
--- a/build/windows/Dockerfile
+++ b/build/windows/Dockerfile
@@ -10,7 +10,6 @@ USER ContainerAdministrator
COPY dist/mingit/ mingit/
COPY dist/docker.exe /
-COPY dist/helm.exe /
COPY dist/kubectl.exe /
COPY dist/mustache-templates /mustache-templates/
COPY dist/portainer.exe /
diff --git a/go.mod b/go.mod
index 1749b8c20..78f3a8adb 100644
--- a/go.mod
+++ b/go.mod
@@ -55,10 +55,11 @@ require (
golang.org/x/sync v0.10.0
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/yaml.v3 v3.0.1
- k8s.io/api v0.29.2
- k8s.io/apimachinery v0.29.2
- k8s.io/client-go v0.29.2
- k8s.io/metrics v0.27.4
+ helm.sh/helm/v3 v3.17.1
+ k8s.io/api v0.32.1
+ k8s.io/apimachinery v0.32.1
+ k8s.io/client-go v0.32.1
+ k8s.io/metrics v0.32.1
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
)
@@ -70,8 +71,12 @@ require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c // indirect
- github.com/BurntSushi/toml v1.3.2 // indirect
- github.com/Masterminds/semver/v3 v3.2.1 // indirect
+ github.com/BurntSushi/toml v1.4.0 // indirect
+ github.com/MakeNowJust/heredoc v1.0.0 // indirect
+ github.com/Masterminds/goutils v1.1.1 // indirect
+ github.com/Masterminds/semver/v3 v3.3.0 // indirect
+ 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/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
@@ -89,9 +94,11 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect
github.com/beorn7/perks v1.0.1 // indirect
+ github.com/blang/semver/v4 v4.0.0 // indirect
github.com/buger/goterm v1.0.4 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/chai2010/gettext-go v1.0.2 // indirect
github.com/cloudflare/cfssl v1.6.4 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/containerd/console v1.0.4 // indirect
@@ -106,8 +113,8 @@ require (
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect
github.com/containers/ocicrypt v1.1.10 // indirect
github.com/containers/storage v1.53.0 // indirect
- github.com/cyphar/filepath-securejoin v0.2.5 // indirect
- github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/cyphar/filepath-securejoin v0.3.6 // 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.18.0 // indirect
github.com/docker/cli-docs-tool v0.8.0 // indirect
@@ -120,39 +127,49 @@ 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 v4.12.0+incompatible // indirect
+ github.com/evanphx/json-patch v5.9.0+incompatible // indirect
+ github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
+ github.com/fatih/color v1.15.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsevents v0.2.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.1 // indirect
+ github.com/go-errors/errors v1.4.2 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.0 // indirect
- github.com/go-logr/logr v1.4.1 // indirect
+ github.com/go-gorp/gorp/v3 v3.1.0 // indirect
+ github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-openapi/jsonpointer v0.19.6 // indirect
+ github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
- github.com/go-openapi/swag v0.22.10 // indirect
+ github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
+ github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
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/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect
+ github.com/gosuri/uitable v0.0.4 // indirect
+ github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
- github.com/imdario/mergo v0.3.16 // indirect
+ github.com/huandu/xstrings v1.5.0 // indirect
github.com/in-toto/in-toto-golang v0.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
+ github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/ansi v1.0.3 // indirect
@@ -162,22 +179,28 @@ require (
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
+ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
+ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.2.2 // indirect
+ github.com/lib/pq v1.10.9 // indirect
+ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
- github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
+ github.com/mitchellh/copystructure v1.2.0 // indirect
+ github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/buildkit v0.17.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
- github.com/moby/spdystream v0.2.0 // indirect
+ github.com/moby/spdystream v0.5.0 // indirect
github.com/moby/sys/capability v0.4.0 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
@@ -188,28 +211,34 @@ require (
github.com/moby/term v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/runtime-spec v1.2.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
+ github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
- github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/prometheus/client_golang v1.17.0 // indirect
- github.com/prometheus/client_model v0.5.0 // indirect
- github.com/prometheus/common v0.44.0 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/prometheus/client_golang v1.19.1 // indirect
+ github.com/prometheus/client_model v0.6.1 // indirect
+ github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc // indirect
github.com/rivo/uniseg v0.4.4 // indirect
+ github.com/rubenv/sql-migrate v1.7.1 // 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
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.0 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
+ github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
@@ -223,44 +252,55 @@ require (
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
github.com/ulikunitz/xz v0.5.11 // indirect
github.com/vbatts/tar-split v0.11.5 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 // indirect
+ github.com/xlab/treeprint v1.2.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0 // indirect
- go.opentelemetry.io/otel v1.25.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
+ go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.25.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 // indirect
- go.opentelemetry.io/otel/metric v1.25.0 // indirect
- go.opentelemetry.io/otel/sdk v1.25.0 // indirect
- go.opentelemetry.io/otel/sdk/metric v1.21.0 // indirect
- go.opentelemetry.io/otel/trace v1.25.0 // indirect
- go.opentelemetry.io/proto/otlp v1.1.0 // indirect
+ go.opentelemetry.io/otel/metric v1.28.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.28.0 // indirect
+ go.opentelemetry.io/otel/sdk/metric v1.28.0 // indirect
+ go.opentelemetry.io/otel/trace v1.28.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
- golang.org/x/time v0.6.0 // indirect
+ golang.org/x/time v0.7.0 // indirect
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.68.0 // indirect
google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
- k8s.io/klog/v2 v2.110.1 // indirect
- k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect
- k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
- sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
- sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
- sigs.k8s.io/yaml v1.3.0 // indirect
+ k8s.io/apiextensions-apiserver v0.32.1 // indirect
+ k8s.io/apiserver v0.32.1 // indirect
+ k8s.io/cli-runtime v0.32.1 // indirect
+ k8s.io/component-base v0.32.1 // indirect
+ k8s.io/klog/v2 v2.130.1 // indirect
+ k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
+ k8s.io/kubectl v0.32.1 // indirect
+ k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
+ oras.land/oras-go v1.2.5 // 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/yaml v1.4.0 // indirect
tags.cncf.io/container-device-interface v0.8.0 // indirect
)
diff --git a/go.sum b/go.sum
index 6427b69de..278c4cdd3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,7 @@
-cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM=
-cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg=
-cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
-cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 h1:59MxjQVfjXsBpLy+dbd2/ELV5ofnUkUZBvWSC85sheA=
@@ -15,12 +13,22 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/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.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
-github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+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/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/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
+github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
-github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
-github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
+github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
+github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
+github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
+github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
+github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@@ -90,7 +98,11 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
+github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
+github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
+github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
+github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
github.com/bugsnag/bugsnag-go v1.0.5-0.20150529004307-13fd6b8acda0 h1:s7+5BfS4WFJoVF9pnB8kBk03S7pZXRdKamnV0FOl5Sc=
@@ -106,13 +118,13 @@ github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyY
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk=
+github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA=
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
github.com/cloudflare/cfssl v1.6.4 h1:NMOvfrEjFfC63K3SGXgAnFdsgkmiq4kATme5BfcqrO8=
github.com/cloudflare/cfssl v1.6.4/go.mod h1:8b3CQMxfWPAeom3zBnGJ6sd+G1NkL5TXqmDXacb+1J0=
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
-github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI=
-github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
github.com/compose-spec/compose-go/v2 v2.4.5 h1:p4ih4Jb6VgGPLPxh3fSFVKAjFHtZd+7HVLCSFzcFx9Y=
@@ -161,14 +173,17 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
-github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
-github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
+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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/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.18.0 h1:rSauXHeJt90NvtXrLK5J992Eb0UPJZs2vV3u1zTf1nE=
@@ -209,13 +224,19 @@ github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxER
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
-github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM=
-github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
-github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
-github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
+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/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/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
+github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
+github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsevents v0.2.0 h1:BRlvlqjvNTfogHfeBOFvSC9N0Ddy+wzQCQukyoD7o/c=
github.com/fsnotify/fsevents v0.2.0/go.mod h1:B3eEk39i4hz8y1zaWS/wPrAP4O6wkIl7HQwKBr1qH/w=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@@ -223,12 +244,16 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
+github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
+github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU=
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us009o8=
github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
+github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
+github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
@@ -237,24 +262,26 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E=
github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw=
+github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
+github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-ldap/ldap/v3 v3.4.1 h1:fU/0xli6HY02ocbMuozHAYsaHLcnkLjvho2r5a34BUU=
github.com/go-ldap/ldap/v3 v3.4.1/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
-github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
-github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
+github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
+github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
-github.com/go-openapi/swag v0.22.10 h1:4y86NVn7Z2yYd6pfS4Z+Nyh3aAUL3Nul+LMbhFKy0gA=
-github.com/go-openapi/swag v0.22.10/go.mod h1:Cnn8BYtRlx6BNE3DPN86f/xkapGIcLWzh3CLEb4C1jI=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -264,13 +291,15 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
github.com/go-playground/validator/v10 v10.12.0 h1:E4gtWgxWxp8YSxExrQFv5BpCahla0PVF2oTTEYaWQGI=
github.com/go-playground/validator/v10 v10.12.0/go.mod h1:hCAPuzYvKdP33pxWa+2+6AIKXEKqjIUyqsNCtbsSJrA=
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
-github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
+github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
+github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
-github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc=
github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
@@ -293,6 +322,10 @@ 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/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=
@@ -305,24 +338,29 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg=
-github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
+github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
+github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
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.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/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/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.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
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/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU=
+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=
+github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -339,8 +377,8 @@ github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uG
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=
-github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
-github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
+github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
+github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU=
github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
@@ -357,8 +395,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
-github.com/jmoiron/sqlx v1.3.3 h1:j82X0bf7oQ27XeqxicSZsTU5suPwKElg3oyxNn43iTk=
-github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
+github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
+github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
@@ -402,9 +440,17 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
+github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
+github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
+github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.2.2 h1:7z68G0FCGvDk646jz1AelTYNYWrTNm0bEcFAo147wt4=
github.com/leodido/go-urn v1.2.2/go.mod h1:kUaIbLZWttglzwNuG0pgsh5vuV6u2YcGBYz1hIPjtOQ=
github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
+github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y=
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
@@ -423,19 +469,27 @@ github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
-github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
+github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
+github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
+github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
+github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
+github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
+github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/mapstructure v0.0.0-20150613213606-2caf8efc9366/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
+github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/buildkit v0.17.2 h1:/jgk/MuXbA7jeXMkknOpHYB+Ct4aNvQHkBB7SxD3D4U=
github.com/moby/buildkit v0.17.2/go.mod h1:vr5vltV8wt4F2jThbNOChfbAklJ0DOW11w36v210hOg=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -444,8 +498,8 @@ github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
-github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8=
-github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c=
+github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
+github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk=
github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
@@ -469,6 +523,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0=
+github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -480,12 +536,12 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU=
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
-github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
-github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
+github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
+github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
-github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
-github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
+github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
+github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
@@ -505,6 +561,10 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
+github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
+github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
+github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI=
+github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -513,24 +573,27 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
+github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
-github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
-github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
+github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
+github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
-github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
+github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
+github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
-github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
-github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
+github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
+github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
@@ -544,11 +607,14 @@ github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
-github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
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/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/rwtodd/Go.Sed v0.0.0-20210816025313-55464686f9ef/go.mod h1:8AEUvGVi2uQ5b24BIhcr0GCcpd/RNAFWaN2CJFrWIIQ=
github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA=
@@ -563,6 +629,8 @@ github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b h1:h+3JX2VoWTFuyQ
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b/go.mod h1:/yeG0My1xr/u+HZrFQ1tOQQQQrOawfyMUH13ai5brBc=
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
@@ -575,8 +643,9 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EE
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/spdx/tools-golang v0.5.3 h1:ialnHeEYUC4+hkm5vJm4qz2x+oEJbS0mAMFrNXdQraY=
github.com/spdx/tools-golang v0.5.3/go.mod h1:/ETOahiAo96Ob0/RAIBmFZw6XN0yTnyr/uFZm2NTMhI=
-github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94 h1:JmfC365KywYwHB946TTiQWEb8kqPY+pybPLoGE9GgVk=
github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
+github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
+github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
@@ -592,6 +661,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -632,6 +703,8 @@ github.com/viney-shih/go-lock v1.1.1 h1:SwzDPPAiHpcwGCr5k8xD15d2gQSo8d4roRYd7TDV
github.com/viney-shih/go-lock v1.1.1/go.mod h1:Yijm78Ljteb3kRiJrbLAxVntkUukGu5uzSxq/xV7OO8=
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b h1:FsyNrX12e5BkplJq7wKOLk0+C6LZ+KGXvuEcKUYm5ss=
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
@@ -641,9 +714,17 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
+github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
+github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
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/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc h1:zkGwegkOW709y0oiAraH/3D8njopUR/pARHv4tZZ6pw=
github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc/go.mod h1:FM4U1E3NzlNMRnSUTU3P1UdukWhYGifqEsjk9fn7BCk=
github.com/zmap/zlint/v3 v3.1.0 h1:WjVytZo79m/L1+/Mlphl09WBob6YTGljN5IGWZFpAv0=
@@ -652,34 +733,34 @@ go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1 h1:SpGay3w+nEwMpfVnbqOLH5gY52/foP8RE8UzTZ1pdSE=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.46.1/go.mod h1:4UoMYEZOC0yN/sPGH76KPkkU7zgiEWYWL9vwmbnTJPE=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 h1:gbhw/u49SS3gkPWiYweQNJGm/uJN5GkI/FrosxSHT7A=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1/go.mod h1:GnOaBaFQ2we3b9AGWJpsBa7v1S5RlQzlC3O7dRMxZhM=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0 h1:cEPbyTSEHlQR89XVlyo78gqluF8Y3oMeBkXGWzQsfXY=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.50.0/go.mod h1:DKdbWcT4GH1D0Y3Sqt/PFXt2naRKDWtU+eE6oLdFNA8=
-go.opentelemetry.io/otel v1.25.0 h1:gldB5FfhRl7OJQbUHt/8s0a7cE8fbsPAtdpRaApKy4k=
-go.opentelemetry.io/otel v1.25.0/go.mod h1:Wa2ds5NOXEMkCmUou1WA7ZBfLTHWIsp034OVD7AO+Vg=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
+go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
+go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 h1:jd0+5t/YynESZqsSyPz+7PAFdEop0dlN0+PkyHYo8oI=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0/go.mod h1:U707O40ee1FpQGyhvqnzmCJm1Wh6OX6GGBVn0E6Uyyk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 h1:bflGWrfYyuulcdxf14V6n9+CoQcu5SAAdHmDPAJnlps=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0/go.mod h1:qcTO4xHAxZLaLxPd60TdE88rxtItPHgHWqOhOGRr0as=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.25.0 h1:dT33yIHtmsqpixFsSQPwNeY5drM9wTcoL8h0FWF4oGM=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.25.0/go.mod h1:h95q0LBGh7hlAC08X2DhSeyIG02YQ0UyioTCVAqRPmc=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0 h1:tIqheXEFWAZ7O8A7m+J0aPTmpJN3YQ7qetUAdkkkKpk=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.21.0/go.mod h1:nUeKExfxAQVbiVFn32YXpXZZHZ61Cc3s3Rn1pDBGAb0=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 h1:Mbi5PKN7u322woPa85d7ebZ+SOvEoPvoiBu+ryHWgfA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0/go.mod h1:e7ciERRhZaOZXVjx5MiL8TK5+Xv7G5Gv5PA2ZDEJdL8=
-go.opentelemetry.io/otel/metric v1.25.0 h1:LUKbS7ArpFL/I2jJHdJcqMGxkRdxpPHE0VU/D4NuEwA=
-go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s=
-go.opentelemetry.io/otel/sdk v1.25.0 h1:PDryEJPC8YJZQSyLY5eqLeafHtG+X7FWnf3aXMtxbqo=
-go.opentelemetry.io/otel/sdk v1.25.0/go.mod h1:oFgzCM2zdsxKzz6zwpTZYLLQsFwc+K0daArPdIhuxkw=
-go.opentelemetry.io/otel/sdk/metric v1.21.0 h1:smhI5oD714d6jHE6Tie36fPx4WDFIg+Y6RfAY4ICcR0=
-go.opentelemetry.io/otel/sdk/metric v1.21.0/go.mod h1:FJ8RAsoPGv/wYMgBdUJXOm+6pzFY3YdljnXtv1SBE8Q=
-go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM=
-go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I=
-go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI=
-go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY=
+go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
+go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
+go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
+go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
+go.opentelemetry.io/otel/sdk/metric v1.28.0 h1:OkuaKgKrgAbYrrY0t92c+cC+2F6hsFNnCQArXCKlg08=
+go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg=
+go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
+go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
+go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
+go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
@@ -769,15 +850,15 @@ 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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
-golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
-golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
+golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
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.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
-golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
+golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
+golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
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=
@@ -805,6 +886,8 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
+gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
@@ -826,26 +909,44 @@ 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.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
-k8s.io/api v0.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A=
-k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0=
-k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8=
-k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU=
-k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg=
-k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA=
-k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
-k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
-k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780=
-k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA=
-k8s.io/metrics v0.27.4 h1:2s04bods7rA507iouGbxD55YrKNlFjLYzm30noOl9Sk=
-k8s.io/metrics v0.27.4/go.mod h1:kRvfhFC7wCQEFvu6H92uiV7v05z3Ty/vtluYT5D2Xpk=
-k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
-k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
-sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
-sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
-sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
-sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
+helm.sh/helm/v3 v3.17.1 h1:gzVoAD+qVuoJU6KDMSAeo0xRJ6N1znRxz3wyuXRmJDk=
+helm.sh/helm/v3 v3.17.1/go.mod h1:nvreuhuR+j78NkQcLC3TYoprCKStLyw5P4T7E5itv2w=
+k8s.io/api v0.32.1 h1:f562zw9cy+GvXzXf0CKlVQ7yHJVYzLfL6JAS4kOAaOc=
+k8s.io/api v0.32.1/go.mod h1:/Yi/BqkuueW1BgpoePYBRdDYfjPF5sgTr5+YqDZra5k=
+k8s.io/apiextensions-apiserver v0.32.1 h1:hjkALhRUeCariC8DiVmb5jj0VjIc1N0DREP32+6UXZw=
+k8s.io/apiextensions-apiserver v0.32.1/go.mod h1:sxWIGuGiYov7Io1fAS2X06NjMIk5CbRHc2StSmbaQto=
+k8s.io/apimachinery v0.32.1 h1:683ENpaCBjma4CYqsmZyhEzrGz6cjn1MY/X2jB2hkZs=
+k8s.io/apimachinery v0.32.1/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
+k8s.io/apiserver v0.32.1 h1:oo0OozRos66WFq87Zc5tclUX2r0mymoVHRq8JmR7Aak=
+k8s.io/apiserver v0.32.1/go.mod h1:UcB9tWjBY7aryeI5zAgzVJB/6k7E97bkr1RgqDz0jPw=
+k8s.io/cli-runtime v0.32.1 h1:19nwZPlYGJPUDbhAxDIS2/oydCikvKMHsxroKNGA2mM=
+k8s.io/cli-runtime v0.32.1/go.mod h1:NJPbeadVFnV2E7B7vF+FvU09mpwYlZCu8PqjzfuOnkY=
+k8s.io/client-go v0.32.1 h1:otM0AxdhdBIaQh7l1Q0jQpmo7WOFIk5FFa4bg6YMdUU=
+k8s.io/client-go v0.32.1/go.mod h1:aTTKZY7MdxUaJ/KiUs8D+GssR9zJZi77ZqtzcGXIiDg=
+k8s.io/component-base v0.32.1 h1:/5IfJ0dHIKBWysGV0yKTFfacZ5yNV1sulPh3ilJjRZk=
+k8s.io/component-base v0.32.1/go.mod h1:j1iMMHi/sqAHeG5z+O9BFNCF698a1u0186zkjMZQ28w=
+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.1 h1:/btLtXLQUU1rWx8AEvX9jrb9LaI6yeezt3sFALhB8M8=
+k8s.io/kubectl v0.32.1/go.mod h1:sezNuyWi1STk4ZNPVRIFfgjqMI6XMf+oCVLjZen/pFQ=
+k8s.io/metrics v0.32.1 h1:Ou4nrEtZS2vFf7OJCf9z3+2kr0A00kQzfoSwxg0gXps=
+k8s.io/metrics v0.32.1/go.mod h1:cLnai9XKYby1tNMX+xe8p9VLzTqrxYPcmqfCBoWObcM=
+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=
+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/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=
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78/go.mod h1:B7Wf0Ya4DHF9Yw+qfZuJijQYkWicqDa+79Ytmmq3Kjg=
tags.cncf.io/container-device-interface v0.8.0 h1:8bCFo/g9WODjWx3m6EYl3GfUG31eKJbaggyBDxEldRc=
diff --git a/pkg/build/info.go b/pkg/build/info.go
index fac320b4f..026a317ef 100644
--- a/pkg/build/info.go
+++ b/pkg/build/info.go
@@ -42,9 +42,6 @@ var (
// DepDockerVersion is the version of the Docker binary shipped with the application.
DepDockerVersion string
- // DepHelmVersion is the version of the Helm binary shipped with the application.
- DepHelmVersion string
-
// DepKubectlVersion is the version of the Kubectl binary shipped with the application.
DepKubectlVersion string
)
@@ -92,7 +89,6 @@ func GetBuildInfo() BuildInfo {
func GetDependenciesInfo() DependenciesInfo {
return DependenciesInfo{
DockerVersion: DepDockerVersion,
- HelmVersion: DepHelmVersion,
KubectlVersion: DepKubectlVersion,
ComposeVersion: DepComposeVersion,
}
diff --git a/pkg/libhelm/binary/get.go b/pkg/libhelm/binary/get.go
deleted file mode 100644
index 0df41bbc8..000000000
--- a/pkg/libhelm/binary/get.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package binary
-
-import (
- "github.com/pkg/errors"
- "github.com/portainer/portainer/pkg/libhelm/options"
-)
-
-// Get runs `helm get` with specified get options.
-// The get options translate to CLI arguments which are passed in to the helm binary when executing install.
-func (hbpm *helmBinaryPackageManager) Get(getOpts options.GetOptions) ([]byte, error) {
- if getOpts.Name == "" || getOpts.ReleaseResource == "" {
- return nil, errors.New("release name and release resource are required")
- }
-
- args := []string{
- string(getOpts.ReleaseResource),
- getOpts.Name,
- }
- if getOpts.Namespace != "" {
- args = append(args, "--namespace", getOpts.Namespace)
- }
-
- result, err := hbpm.runWithKubeConfig("get", args, getOpts.KubernetesClusterAccess, getOpts.Env)
- if err != nil {
- return nil, errors.Wrap(err, "failed to run helm get on specified args")
- }
-
- return result, nil
-}
diff --git a/pkg/libhelm/binary/helm_package.go b/pkg/libhelm/binary/helm_package.go
deleted file mode 100644
index 2e64a8a2b..000000000
--- a/pkg/libhelm/binary/helm_package.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package binary
-
-import (
- "bytes"
- "os"
- "os/exec"
- "path"
- "runtime"
-
- "github.com/pkg/errors"
- "github.com/portainer/portainer/pkg/libhelm/options"
-)
-
-// helmBinaryPackageManager is a wrapper for the helm binary which implements HelmPackageManager
-type helmBinaryPackageManager struct {
- binaryPath string
-}
-
-// NewHelmBinaryPackageManager initializes a new HelmPackageManager service.
-func NewHelmBinaryPackageManager(binaryPath string) *helmBinaryPackageManager {
- return &helmBinaryPackageManager{binaryPath: binaryPath}
-}
-
-// runWithKubeConfig will execute run against the provided Kubernetes cluster with kubeconfig as cli arguments.
-func (hbpm *helmBinaryPackageManager) runWithKubeConfig(command string, args []string, kca *options.KubernetesClusterAccess, env []string) ([]byte, error) {
- cmdArgs := make([]string, 0)
- if kca != nil {
- cmdArgs = append(cmdArgs, "--kube-apiserver", kca.ClusterServerURL)
- cmdArgs = append(cmdArgs, "--kube-token", kca.AuthToken)
- cmdArgs = append(cmdArgs, "--kube-ca-file", kca.CertificateAuthorityFile)
- }
- cmdArgs = append(cmdArgs, args...)
- return hbpm.run(command, cmdArgs, env)
-}
-
-// run will execute helm command against the provided Kubernetes cluster.
-// The endpointId and authToken are dynamic params (based on the user) that allow helm to execute commands
-// in the context of the current user against specified k8s cluster.
-func (hbpm *helmBinaryPackageManager) run(command string, args []string, env []string) ([]byte, error) {
- cmdArgs := make([]string, 0)
- cmdArgs = append(cmdArgs, command)
- cmdArgs = append(cmdArgs, args...)
-
- helmPath := path.Join(hbpm.binaryPath, "helm")
- if runtime.GOOS == "windows" {
- helmPath = path.Join(hbpm.binaryPath, "helm.exe")
- }
-
- var stderr bytes.Buffer
- cmd := exec.Command(helmPath, cmdArgs...)
- cmd.Stderr = &stderr
-
- cmd.Env = os.Environ()
- cmd.Env = append(cmd.Env, env...)
-
- output, err := cmd.Output()
- if err != nil {
- return nil, errors.Wrap(err, stderr.String())
- }
-
- return output, nil
-}
diff --git a/pkg/libhelm/binary/install.go b/pkg/libhelm/binary/install.go
deleted file mode 100644
index 937df1500..000000000
--- a/pkg/libhelm/binary/install.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package binary
-
-import (
- "github.com/portainer/portainer/pkg/libhelm/options"
- "github.com/portainer/portainer/pkg/libhelm/release"
-
- "github.com/pkg/errors"
- "github.com/segmentio/encoding/json"
-)
-
-// Install runs `helm install` with specified install options.
-// The install options translate to CLI arguments which are passed in to the helm binary when executing install.
-func (hbpm *helmBinaryPackageManager) Install(installOpts options.InstallOptions) (*release.Release, error) {
- if installOpts.Name == "" {
- installOpts.Name = "--generate-name"
- }
- args := []string{
- installOpts.Name,
- installOpts.Chart,
- "--repo", installOpts.Repo,
- "--output", "json",
- }
- if installOpts.Namespace != "" {
- args = append(args, "--namespace", installOpts.Namespace)
- }
- if installOpts.ValuesFile != "" {
- args = append(args, "--values", installOpts.ValuesFile)
- }
- if installOpts.Wait {
- args = append(args, "--wait")
- }
- if installOpts.PostRenderer != "" {
- args = append(args, "--post-renderer", installOpts.PostRenderer)
- }
-
- result, err := hbpm.runWithKubeConfig("install", args, installOpts.KubernetesClusterAccess, installOpts.Env)
- if err != nil {
- return nil, errors.Wrap(err, "failed to run helm install on specified args")
- }
-
- response := &release.Release{}
- err = json.Unmarshal(result, &response)
- if err != nil {
- return nil, errors.Wrap(err, "failed to unmarshal helm install response to Release struct")
- }
-
- return response, nil
-}
diff --git a/pkg/libhelm/binary/install_test.go b/pkg/libhelm/binary/install_test.go
deleted file mode 100644
index 3d6c74707..000000000
--- a/pkg/libhelm/binary/install_test.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package binary
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "testing"
-
- "github.com/portainer/portainer/pkg/libhelm/options"
- "github.com/stretchr/testify/assert"
-)
-
-func createValuesFile(values string) (string, error) {
- file, err := os.CreateTemp("", "helm-values")
- if err != nil {
- return "", err
- }
-
- _, err = file.WriteString(values)
- if err != nil {
- file.Close()
- return "", err
- }
-
- err = file.Close()
- if err != nil {
- return "", err
- }
-
- return file.Name(), nil
-}
-
-// getHelmBinaryPath is helper function to get local helm binary path (if helm is in path)
-func getHelmBinaryPath() (string, error) {
- path, err := exec.LookPath("helm")
- if err != nil {
- return "", err
- }
- dir, err := filepath.Abs(filepath.Dir(path))
- if err != nil {
- return "", err
- }
- return dir, nil
-}
-
-func Test_Install(t *testing.T) {
- ensureIntegrationTest(t)
- is := assert.New(t)
-
- path, err := getHelmBinaryPath()
- is.NoError(err, "helm binary must exist in path to run tests")
-
- hbpm := NewHelmBinaryPackageManager(path)
-
- t.Run("successfully installs nginx chart with name test-nginx", func(t *testing.T) {
- // helm install test-nginx --repo https://kubernetes.github.io/ingress-nginx nginx
- installOpts := options.InstallOptions{
- Name: "test-nginx",
- Chart: "nginx",
- Repo: "https://kubernetes.github.io/ingress-nginx",
- }
-
- release, err := hbpm.Install(installOpts)
- defer hbpm.run("uninstall", []string{"test-nginx"}, nil)
-
- is.NoError(err, "should successfully install release", release)
- })
-
- t.Run("successfully installs nginx chart with generated name", func(t *testing.T) {
- // helm install --generate-name --repo https://kubernetes.github.io/ingress-nginx nginx
- installOpts := options.InstallOptions{
- Chart: "nginx",
- Repo: "https://kubernetes.github.io/ingress-nginx",
- }
- release, err := hbpm.Install(installOpts)
- defer hbpm.run("uninstall", []string{release.Name}, nil)
-
- is.NoError(err, "should successfully install release", release)
- })
-
- t.Run("successfully installs nginx with values", func(t *testing.T) {
- // helm install test-nginx-2 --repo https://kubernetes.github.io/ingress-nginx nginx --values /tmp/helm-values3161785816
- values, err := createValuesFile("service:\n port: 8081")
- is.NoError(err, "should create a values file")
-
- defer os.Remove(values)
-
- installOpts := options.InstallOptions{
- Name: "test-nginx-2",
- Chart: "nginx",
- Repo: "https://kubernetes.github.io/ingress-nginx",
- ValuesFile: values,
- }
- release, err := hbpm.Install(installOpts)
- defer hbpm.run("uninstall", []string{"test-nginx-2"}, nil)
-
- is.NoError(err, "should successfully install release", release)
- })
-
- t.Run("successfully installs portainer chart with name portainer-test", func(t *testing.T) {
- // helm install portainer-test portainer --repo https://portainer.github.io/k8s/
- installOpts := options.InstallOptions{
- Name: "portainer-test",
- Chart: "portainer",
- Repo: "https://portainer.github.io/k8s/",
- }
- release, err := hbpm.Install(installOpts)
- defer hbpm.run("uninstall", []string{installOpts.Name}, nil)
-
- is.NoError(err, "should successfully install release", release)
- })
-}
-
-func ensureIntegrationTest(t *testing.T) {
- if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok {
- t.Skip("skip an integration test")
- }
-}
diff --git a/pkg/libhelm/binary/list.go b/pkg/libhelm/binary/list.go
deleted file mode 100644
index 886d4c5ea..000000000
--- a/pkg/libhelm/binary/list.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package binary
-
-import (
- "github.com/portainer/portainer/pkg/libhelm/options"
- "github.com/portainer/portainer/pkg/libhelm/release"
-
- "github.com/pkg/errors"
- "github.com/segmentio/encoding/json"
-)
-
-// List runs `helm list --output json --filter --selector --namespace ` with specified list options.
-// The list options translate to CLI args the helm binary
-func (hbpm *helmBinaryPackageManager) List(listOpts options.ListOptions) ([]release.ReleaseElement, error) {
- args := []string{"--output", "json"}
-
- if listOpts.Filter != "" {
- args = append(args, "--filter", listOpts.Filter)
- }
- if listOpts.Selector != "" {
- args = append(args, "--selector", listOpts.Selector)
- }
- if listOpts.Namespace != "" {
- args = append(args, "--namespace", listOpts.Namespace)
- }
-
- result, err := hbpm.runWithKubeConfig("list", args, listOpts.KubernetesClusterAccess, listOpts.Env)
- if err != nil {
- return []release.ReleaseElement{}, errors.Wrap(err, "failed to run helm list on specified args")
- }
-
- response := []release.ReleaseElement{}
- err = json.Unmarshal(result, &response)
- if err != nil {
- return []release.ReleaseElement{}, errors.Wrap(err, "failed to unmarshal helm list response to releastElement list")
- }
-
- return response, nil
-}
diff --git a/pkg/libhelm/binary/search_repo.go b/pkg/libhelm/binary/search_repo.go
deleted file mode 100644
index f875589cb..000000000
--- a/pkg/libhelm/binary/search_repo.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package binary
-
-// Package common implements common functionality for the helm.
-// The functionality does not rely on the implementation of `HelmPackageManager`
-
-import (
- "net/http"
- "net/url"
- "path"
- "time"
-
- "github.com/portainer/portainer/pkg/libhelm/options"
-
- "github.com/pkg/errors"
- "github.com/segmentio/encoding/json"
- "gopkg.in/yaml.v3"
-)
-
-var errRequiredSearchOptions = errors.New("repo is required")
-var errInvalidRepoURL = errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid")
-
-type File struct {
- APIVersion string `yaml:"apiVersion" json:"apiVersion"`
- Entries map[string][]Entry `yaml:"entries" json:"entries"`
- Generated string `yaml:"generated" json:"generated"`
-}
-
-type Annotations struct {
- Category string `yaml:"category" json:"category"`
-}
-
-type Entry struct {
- Annotations *Annotations `yaml:"annotations" json:"annotations,omitempty"`
- Created string `yaml:"created" json:"created"`
- Deprecated bool `yaml:"deprecated" json:"deprecated"`
- Description string `yaml:"description" json:"description"`
- Digest string `yaml:"digest" json:"digest"`
- Home string `yaml:"home" json:"home"`
- Name string `yaml:"name" json:"name"`
- Sources []string `yaml:"sources" json:"sources"`
- Urls []string `yaml:"urls" json:"urls"`
- Version string `yaml:"version" json:"version"`
- Icon string `yaml:"icon" json:"icon,omitempty"`
-}
-
-// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller.
-// The functionality is similar to that of what `helm search repo [chart] --repo ` CLI runs;
-// this approach is used instead since the `helm search repo` requires a repo to be added to the global helm cache
-func (hbpm *helmBinaryPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
- if searchRepoOpts.Repo == "" {
- return nil, errRequiredSearchOptions
- }
-
- client := searchRepoOpts.Client
-
- if client == nil {
- // The current index.yaml is ~9MB on bitnami.
- // At a slow @2mbit download = 40s. @100bit = ~1s.
- // I'm seeing 3 - 4s over wifi.
- // Give ample time but timeout for now. Can be improved in the future
- client = &http.Client{
- Timeout: 300 * time.Second,
- Transport: http.DefaultTransport,
- }
- }
-
- // Allow redirect behavior to be overridden if specified.
- if client.CheckRedirect == nil {
- client.CheckRedirect = defaultCheckRedirect
- }
-
- url, err := url.ParseRequestURI(searchRepoOpts.Repo)
- if err != nil {
- return nil, errors.Wrap(err, "invalid helm chart URL: "+searchRepoOpts.Repo)
- }
-
- url.Path = path.Join(url.Path, "index.yaml")
- resp, err := client.Get(url.String())
- if err != nil {
- return nil, errInvalidRepoURL
- }
- defer resp.Body.Close()
-
- var file File
- err = yaml.NewDecoder(resp.Body).Decode(&file)
- if err != nil {
- return nil, errInvalidRepoURL
- }
-
- // Validate index.yaml
- if file.APIVersion == "" || file.Entries == nil {
- return nil, errInvalidRepoURL
- }
-
- result, err := json.Marshal(file)
- if err != nil {
- return nil, errInvalidRepoURL
- }
-
- return result, nil
-}
-
-// defaultCheckRedirect is a default CheckRedirect for helm
-// We don't allow redirects to URLs not ending in index.yaml
-// After that we follow the go http client behavior which is to stop
-// after a maximum of 10 redirects
-func defaultCheckRedirect(req *http.Request, via []*http.Request) error {
- // The request url must end in index.yaml
- if path.Base(req.URL.Path) != "index.yaml" {
- return errors.New("the request URL must end in index.yaml")
- }
-
- // default behavior below
- if len(via) >= 10 {
- return errors.New("stopped after 10 redirects")
- }
- return nil
-}
diff --git a/pkg/libhelm/binary/search_repo_test.go b/pkg/libhelm/binary/search_repo_test.go
deleted file mode 100644
index 48786a0d7..000000000
--- a/pkg/libhelm/binary/search_repo_test.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package binary
-
-import (
- "testing"
-
- "github.com/portainer/portainer/pkg/libhelm/libhelmtest"
- "github.com/portainer/portainer/pkg/libhelm/options"
- "github.com/stretchr/testify/assert"
-)
-
-func Test_SearchRepo(t *testing.T) {
- libhelmtest.EnsureIntegrationTest(t)
- is := assert.New(t)
-
- hpm := NewHelmBinaryPackageManager("")
-
- type testCase struct {
- name string
- url string
- invalid bool
- }
-
- tests := []testCase{
- {"not a helm repo", "https://portainer.io", true},
- {"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", false},
- {"portainer helm repo", "https://portainer.github.io/k8s/", false},
- {"gitlap helm repo with trailing slash", "https://charts.gitlab.io/", false},
- {"elastic helm repo with trailing slash", "https://helm.elastic.co/", false},
- {"fabric8.io helm repo with trailing slash", "https://fabric8.io/helm/", false},
- {"lensesio helm repo without trailing slash", "https://lensesio.github.io/kafka-helm-charts", false},
- }
-
- for _, test := range tests {
- func(tc testCase) {
- t.Run(tc.name, func(t *testing.T) {
- t.Parallel()
- response, err := hpm.SearchRepo(options.SearchRepoOptions{Repo: tc.url})
- if tc.invalid {
- is.Errorf(err, "error expected: %s", tc.url)
- } else {
- is.NoError(err, "no error expected: %s", tc.url)
- }
-
- if err == nil {
- is.NotEmpty(response, "response expected")
- }
- })
- }(test)
- }
-}
diff --git a/pkg/libhelm/binary/show.go b/pkg/libhelm/binary/show.go
deleted file mode 100644
index 848e8ab26..000000000
--- a/pkg/libhelm/binary/show.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package binary
-
-import (
- "github.com/pkg/errors"
- "github.com/portainer/portainer/pkg/libhelm/options"
-)
-
-var errRequiredShowOptions = errors.New("chart, repo and output format are required")
-
-// Show runs `helm show --repo ` with specified show options.
-// The show options translate to CLI arguments which are passed in to the helm binary when executing install.
-func (hbpm *helmBinaryPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
- if showOpts.Chart == "" || showOpts.Repo == "" || showOpts.OutputFormat == "" {
- return nil, errRequiredShowOptions
- }
-
- args := []string{
- string(showOpts.OutputFormat),
- showOpts.Chart,
- "--repo", showOpts.Repo,
- }
-
- result, err := hbpm.run("show", args, showOpts.Env)
- if err != nil {
- return nil, errors.New("the request failed since either the Helm repository was not found or the chart does not exist")
- }
-
- return result, nil
-}
diff --git a/pkg/libhelm/binary/uninstall.go b/pkg/libhelm/binary/uninstall.go
deleted file mode 100644
index b926e8ee5..000000000
--- a/pkg/libhelm/binary/uninstall.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package binary
-
-import (
- "github.com/pkg/errors"
- "github.com/portainer/portainer/pkg/libhelm/options"
-)
-
-var errRequiredUninstallOptions = errors.New("release name is required")
-
-// Uninstall runs `helm uninstall --namespace ` with specified uninstall options.
-// The uninstall options translate to CLI arguments which are passed in to the helm binary when executing uninstall.
-func (hbpm *helmBinaryPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
- if uninstallOpts.Name == "" {
- return errRequiredUninstallOptions
- }
-
- args := []string{uninstallOpts.Name}
-
- if uninstallOpts.Namespace != "" {
- args = append(args, "--namespace", uninstallOpts.Namespace)
- }
-
- _, err := hbpm.runWithKubeConfig("uninstall", args, uninstallOpts.KubernetesClusterAccess, uninstallOpts.Env)
- if err != nil {
- return errors.Wrap(err, "failed to run helm uninstall on specified args")
- }
-
- return nil
-}
diff --git a/pkg/libhelm/manager.go b/pkg/libhelm/manager.go
index f378b2e55..1396195bd 100644
--- a/pkg/libhelm/manager.go
+++ b/pkg/libhelm/manager.go
@@ -1,22 +1,11 @@
package libhelm
import (
- "errors"
-
- "github.com/portainer/portainer/pkg/libhelm/binary"
+ "github.com/portainer/portainer/pkg/libhelm/sdk"
+ "github.com/portainer/portainer/pkg/libhelm/types"
)
-// HelmConfig is a struct that holds the configuration for the Helm package manager
-type HelmConfig struct {
- BinaryPath string `example:"/portainer/dist"`
-}
-
-var errBinaryPathNotSpecified = errors.New("binary path not specified")
-
// NewHelmPackageManager returns a new instance of HelmPackageManager based on HelmConfig
-func NewHelmPackageManager(config HelmConfig) (HelmPackageManager, error) {
- if config.BinaryPath != "" {
- return binary.NewHelmBinaryPackageManager(config.BinaryPath), nil
- }
- return nil, errBinaryPathNotSpecified
+func NewHelmPackageManager() (types.HelmPackageManager, error) {
+ return sdk.NewHelmSDKPackageManager(), nil
}
diff --git a/pkg/libhelm/options/cluster_access.go b/pkg/libhelm/options/cluster_access.go
index a66acdc5c..1fe079c12 100644
--- a/pkg/libhelm/options/cluster_access.go
+++ b/pkg/libhelm/options/cluster_access.go
@@ -2,6 +2,9 @@ package options
// KubernetesClusterAccess represents core details which can be used to generate KubeConfig file/data
type KubernetesClusterAccess struct {
+ ClusterName string `example:"portainer-cluster-endpoint-1"`
+ ContextName string `example:"portainer-ctx-endpoint-1"`
+ UserName string `example:"portainer-user-endpoint-1"`
ClusterServerURL string `example:"https://mycompany.k8s.com"`
CertificateAuthorityFile string `example:"/data/tls/localhost.crt"`
AuthToken string `example:"ey..."`
diff --git a/pkg/libhelm/sdk/client.go b/pkg/libhelm/sdk/client.go
new file mode 100644
index 000000000..7989e4558
--- /dev/null
+++ b/pkg/libhelm/sdk/client.go
@@ -0,0 +1,165 @@
+package sdk
+
+import (
+ "github.com/pkg/errors"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/rs/zerolog/log"
+ "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v3/pkg/chartutil"
+ "helm.sh/helm/v3/pkg/cli"
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/client-go/discovery"
+ "k8s.io/client-go/discovery/cached/memory"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/restmapper"
+ "k8s.io/client-go/tools/clientcmd"
+ "k8s.io/client-go/tools/clientcmd/api"
+)
+
+// newRESTClientGetter creates a custom RESTClientGetter using the provided client config
+type clientConfigGetter struct {
+ clientConfig clientcmd.ClientConfig
+ namespace string
+}
+
+// initActionConfig initializes the action configuration with kubernetes config
+func (hspm *HelmSDKPackageManager) initActionConfig(actionConfig *action.Configuration, namespace string, k8sAccess *options.KubernetesClusterAccess) error {
+ // If namespace is not provided, use the default namespace
+ if namespace == "" {
+ namespace = "default"
+ }
+
+ if k8sAccess == nil {
+ // Use default kubeconfig
+ settings := cli.New()
+ clientGetter := settings.RESTClientGetter()
+ return actionConfig.Init(clientGetter, namespace, "secret", hspm.logf)
+ }
+
+ // Create client config
+ configAPI := generateConfigAPI(namespace, k8sAccess)
+ clientConfig := clientcmd.NewDefaultClientConfig(*configAPI, &clientcmd.ConfigOverrides{})
+
+ // Create a custom RESTClientGetter that uses our in-memory config
+ clientGetter, err := newRESTClientGetter(clientConfig, namespace)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("cluster_name", k8sAccess.ClusterName).
+ Str("cluster_url", k8sAccess.ClusterServerURL).
+ Str("user_name", k8sAccess.UserName).
+ Err(err).
+ Msg("failed to create client getter")
+ return err
+ }
+
+ return actionConfig.Init(clientGetter, namespace, "secret", hspm.logf)
+}
+
+// generateConfigAPI generates a new kubeconfig configuration
+func generateConfigAPI(namespace string, k8sAccess *options.KubernetesClusterAccess) *api.Config {
+ // Create in-memory kubeconfig configuration
+ configAPI := api.NewConfig()
+
+ // Create cluster
+ cluster := api.NewCluster()
+ cluster.Server = k8sAccess.ClusterServerURL
+
+ if k8sAccess.CertificateAuthorityFile != "" {
+ // If we have a CA file, use it
+ cluster.CertificateAuthority = k8sAccess.CertificateAuthorityFile
+ } else {
+ // Otherwise skip TLS verification
+ cluster.InsecureSkipTLSVerify = true
+ }
+
+ // Create auth info with token
+ authInfo := api.NewAuthInfo()
+ authInfo.Token = k8sAccess.AuthToken
+
+ // Create context
+ context := api.NewContext()
+ context.Cluster = k8sAccess.ClusterName
+ context.AuthInfo = k8sAccess.UserName
+ context.Namespace = namespace
+
+ // Add to config
+ configAPI.Clusters[k8sAccess.ClusterName] = cluster
+ configAPI.AuthInfos[k8sAccess.UserName] = authInfo
+ configAPI.Contexts[k8sAccess.ContextName] = context
+ configAPI.CurrentContext = k8sAccess.ContextName
+
+ return configAPI
+}
+
+func newRESTClientGetter(clientConfig clientcmd.ClientConfig, namespace string) (*clientConfigGetter, error) {
+ if clientConfig == nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Msg("client config is nil")
+
+ return nil, errors.New("client config provided during the helm client initialization was nil. Check the kubernetes cluster access configuration")
+ }
+
+ return &clientConfigGetter{
+ clientConfig: clientConfig,
+ namespace: namespace,
+ }, nil
+}
+
+func (c *clientConfigGetter) ToRESTConfig() (*rest.Config, error) {
+ if c.clientConfig == nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Msg("client config is nil")
+
+ return nil, errors.New("client config provided during the helm client initialization was nil. Check the kubernetes cluster access configuration")
+ }
+
+ return c.clientConfig.ClientConfig()
+}
+
+func (c *clientConfigGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
+ config, err := c.ToRESTConfig()
+ if err != nil {
+ return nil, err
+ }
+
+ // Create the discovery client
+ discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ // Wrap the discovery client with a cached discovery client
+ return memory.NewMemCacheClient(discoveryClient), nil
+}
+
+func (c *clientConfigGetter) ToRESTMapper() (meta.RESTMapper, error) {
+ discoveryClient, err := c.ToDiscoveryClient()
+ if err != nil {
+ return nil, err
+ }
+
+ // Create a REST mapper from the discovery client
+ return restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient), nil
+}
+
+func (c *clientConfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
+ return c.clientConfig
+}
+
+// parseValues parses YAML values data into a map
+func (hspm *HelmSDKPackageManager) parseValues(data []byte) (map[string]any, error) {
+ // Use Helm's built-in chartutil.ReadValues which properly handles the conversion
+ // from map[interface{}]interface{} to map[string]interface{}
+ return chartutil.ReadValues(data)
+}
+
+// logf is a log helper function for Helm
+func (hspm *HelmSDKPackageManager) logf(format string, v ...any) {
+ // Use zerolog for structured logging
+ log.Debug().
+ Str("context", "HelmClient").
+ Msgf(format, v...)
+}
diff --git a/pkg/libhelm/sdk/client_test.go b/pkg/libhelm/sdk/client_test.go
new file mode 100644
index 000000000..9dd7afffe
--- /dev/null
+++ b/pkg/libhelm/sdk/client_test.go
@@ -0,0 +1,156 @@
+package sdk
+
+import (
+ "testing"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/stretchr/testify/assert"
+ "helm.sh/helm/v3/pkg/action"
+ "k8s.io/client-go/tools/clientcmd"
+ "k8s.io/client-go/tools/clientcmd/api"
+)
+
+func Test_InitActionConfig(t *testing.T) {
+ is := assert.New(t)
+ hspm := NewHelmSDKPackageManager()
+
+ t.Run("with nil k8sAccess should use default kubeconfig", func(t *testing.T) {
+ actionConfig := new(action.Configuration)
+ err := hspm.(*HelmSDKPackageManager).initActionConfig(actionConfig, "default", nil)
+
+ // The function should not fail by design, even when not running in a k8s environment
+ is.NoError(err, "should not return error when not in k8s environment")
+ })
+
+ t.Run("with k8sAccess should create in-memory config", func(t *testing.T) {
+ actionConfig := new(action.Configuration)
+ k8sAccess := &options.KubernetesClusterAccess{
+ ClusterServerURL: "https://kubernetes.default.svc",
+ AuthToken: "test-token",
+ }
+
+ // The function should not fail by design
+ err := hspm.(*HelmSDKPackageManager).initActionConfig(actionConfig, "default", k8sAccess)
+ is.NoError(err, "should not return error when using in-memory config")
+ })
+
+ t.Run("with k8sAccess and CA file should create config with CA", func(t *testing.T) {
+ actionConfig := new(action.Configuration)
+ k8sAccess := &options.KubernetesClusterAccess{
+ ClusterServerURL: "https://kubernetes.default.svc",
+ AuthToken: "test-token",
+ CertificateAuthorityFile: "/path/to/ca.crt",
+ }
+
+ // The function should not fail by design
+ err := hspm.(*HelmSDKPackageManager).initActionConfig(actionConfig, "default", k8sAccess)
+ is.NoError(err, "should not return error when using in-memory config with CA")
+ })
+}
+
+func Test_ClientConfigGetter(t *testing.T) {
+ is := assert.New(t)
+
+ // Create a mock client config
+ configAPI := api.NewConfig()
+
+ // Create cluster
+ cluster := api.NewCluster()
+ cluster.Server = "https://kubernetes.default.svc"
+ cluster.InsecureSkipTLSVerify = true
+
+ // Create auth info
+ authInfo := api.NewAuthInfo()
+ authInfo.Token = "test-token"
+
+ // Create context
+ context := api.NewContext()
+ context.Cluster = "test-cluster"
+ context.AuthInfo = "test-user"
+ context.Namespace = "default"
+
+ // Add to config
+ configAPI.Clusters["test-cluster"] = cluster
+ configAPI.AuthInfos["test-user"] = authInfo
+ configAPI.Contexts["test-context"] = context
+ configAPI.CurrentContext = "test-context"
+
+ clientConfig := clientcmd.NewDefaultClientConfig(*configAPI, &clientcmd.ConfigOverrides{})
+
+ // Create client config getter
+ clientGetter, err := newRESTClientGetter(clientConfig, "default")
+ is.NoError(err, "should not return error when creating client getter")
+
+ // Test ToRESTConfig
+ restConfig, err := clientGetter.ToRESTConfig()
+ is.NoError(err, "should not return error when creating REST config")
+ is.NotNil(restConfig, "should return non-nil REST config")
+ is.Equal("https://kubernetes.default.svc", restConfig.Host, "host should be https://kubernetes.default.svc")
+ is.Equal("test-token", restConfig.BearerToken, "bearer token should be test-token")
+
+ // Test ToDiscoveryClient
+ discoveryClient, err := clientGetter.ToDiscoveryClient()
+ is.NoError(err, "should not return error when creating discovery client")
+ is.NotNil(discoveryClient, "should return non-nil discovery client")
+
+ // Test ToRESTMapper
+ restMapper, err := clientGetter.ToRESTMapper()
+ is.NoError(err, "should not return error when creating REST mapper")
+ is.NotNil(restMapper, "should return non-nil REST mapper")
+
+ // Test ToRawKubeConfigLoader
+ config := clientGetter.ToRawKubeConfigLoader()
+ is.NotNil(config, "should return non-nil config loader")
+}
+
+func Test_ParseValues(t *testing.T) {
+ is := assert.New(t)
+ hspm := NewHelmSDKPackageManager()
+
+ t.Run("should parse valid YAML values", func(t *testing.T) {
+ yamlData := []byte(`
+service:
+ type: ClusterIP
+ port: 80
+resources:
+ limits:
+ cpu: 100m
+ memory: 128Mi
+`)
+ values, err := hspm.(*HelmSDKPackageManager).parseValues(yamlData)
+ is.NoError(err, "should parse valid YAML without error")
+ is.NotNil(values, "should return non-nil values")
+
+ // Verify structure
+ service, ok := values["service"].(map[string]interface{})
+ is.True(ok, "service should be a map")
+ is.Equal("ClusterIP", service["type"], "service type should be ClusterIP")
+ is.Equal(float64(80), service["port"], "service port should be 80")
+
+ resources, ok := values["resources"].(map[string]interface{})
+ is.True(ok, "resources should be a map")
+ limits, ok := resources["limits"].(map[string]interface{})
+ is.True(ok, "limits should be a map")
+ is.Equal("100m", limits["cpu"], "cpu limit should be 100m")
+ is.Equal("128Mi", limits["memory"], "memory limit should be 128Mi")
+ })
+
+ t.Run("should handle invalid YAML", func(t *testing.T) {
+ yamlData := []byte(`
+service:
+ type: ClusterIP
+ port: 80
+ invalid yaml
+`)
+ _, err := hspm.(*HelmSDKPackageManager).parseValues(yamlData)
+ is.Error(err, "should return error for invalid YAML")
+ })
+
+ t.Run("should handle empty YAML", func(t *testing.T) {
+ yamlData := []byte(``)
+ values, err := hspm.(*HelmSDKPackageManager).parseValues(yamlData)
+ is.NoError(err, "should not return error for empty YAML")
+ is.NotNil(values, "should return non-nil values for empty YAML")
+ is.Len(values, 0, "should return empty map for empty YAML")
+ })
+}
diff --git a/pkg/libhelm/sdk/install.go b/pkg/libhelm/sdk/install.go
new file mode 100644
index 000000000..26b50927b
--- /dev/null
+++ b/pkg/libhelm/sdk/install.go
@@ -0,0 +1,210 @@
+package sdk
+
+import (
+ "os"
+
+ "github.com/pkg/errors"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/rs/zerolog/log"
+ "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v3/pkg/chart"
+ "helm.sh/helm/v3/pkg/chart/loader"
+ "helm.sh/helm/v3/pkg/downloader"
+ "helm.sh/helm/v3/pkg/getter"
+ "helm.sh/helm/v3/pkg/postrender"
+)
+
+// Install implements the HelmPackageManager interface by using the Helm SDK to install a chart.
+func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) (*release.Release, error) {
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("chart", installOpts.Chart).
+ Str("name", installOpts.Name).
+ Str("namespace", installOpts.Namespace).
+ Str("repo", installOpts.Repo).
+ Bool("wait", installOpts.Wait).
+ Msg("Installing Helm chart")
+
+ if installOpts.Name == "" {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart", installOpts.Chart).
+ Str("name", installOpts.Name).
+ Str("namespace", installOpts.Namespace).
+ Str("repo", installOpts.Repo).
+ Bool("wait", installOpts.Wait).
+ Msg("Name is required for helm release installation")
+ return nil, errors.New("name is required for helm release installation")
+ }
+
+ // Initialize action configuration with kubernetes config
+ actionConfig := new(action.Configuration)
+ err := hspm.initActionConfig(actionConfig, installOpts.Namespace, installOpts.KubernetesClusterAccess)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart", installOpts.Chart).
+ Str("namespace", installOpts.Namespace).
+ Err(err).
+ Msg("Failed to initialize helm configuration for helm release installation")
+ return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation")
+ }
+
+ installClient, err := initInstallClient(actionConfig, installOpts)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to initialize helm install client for helm release installation")
+ return nil, errors.Wrap(err, "failed to initialize helm install client for helm release installation")
+ }
+
+ values, err := hspm.GetHelmValuesFromFile(installOpts.ValuesFile)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to get Helm values from file for helm release installation")
+ return nil, errors.Wrap(err, "failed to get Helm values from file for helm release installation")
+ }
+
+ chart, err := hspm.loadAndValidateChart(installClient, installOpts)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to load and validate chart for helm release installation")
+ return nil, errors.Wrap(err, "failed to load and validate chart for helm release installation")
+ }
+
+ // Run the installation
+ log.Info().
+ Str("context", "HelmClient").
+ Str("chart", installOpts.Chart).
+ Str("name", installOpts.Name).
+ Str("namespace", installOpts.Namespace).
+ Msg("Running chart installation for helm release")
+
+ helmRelease, err := installClient.Run(chart, values)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart", installOpts.Chart).
+ Str("name", installOpts.Name).
+ Str("namespace", installOpts.Namespace).
+ Err(err).
+ Msg("Failed to install helm chart for helm release installation")
+ return nil, errors.Wrap(err, "helm was not able to install the chart for helm release installation")
+ }
+
+ return &release.Release{
+ Name: helmRelease.Name,
+ Namespace: helmRelease.Namespace,
+ Chart: release.Chart{
+ Metadata: &release.Metadata{
+ Name: helmRelease.Chart.Metadata.Name,
+ Version: helmRelease.Chart.Metadata.Version,
+ AppVersion: helmRelease.Chart.Metadata.AppVersion,
+ },
+ },
+ Labels: helmRelease.Labels,
+ Version: helmRelease.Version,
+ Manifest: helmRelease.Manifest,
+ }, nil
+}
+
+// loadAndValidateChart locates and loads the chart, and validates it.
+// it also checks for chart dependencies and updates them if necessary.
+// it returns the chart information.
+func (hspm *HelmSDKPackageManager) loadAndValidateChart(installClient *action.Install, installOpts options.InstallOptions) (*chart.Chart, error) {
+ // Locate and load the chart
+ chartPath, err := installClient.ChartPathOptions.LocateChart(installOpts.Chart, hspm.settings)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart", installOpts.Chart).
+ Err(err).
+ Msg("Failed to locate chart for helm release installation")
+ return nil, errors.Wrapf(err, "failed to find the helm chart at the path: %s/%s", installOpts.Repo, installOpts.Chart)
+ }
+
+ chartReq, err := loader.Load(chartPath)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart_path", chartPath).
+ Err(err).
+ Msg("Failed to load chart for helm release installation")
+ return nil, errors.Wrap(err, "failed to load chart for helm release installation")
+ }
+
+ // Check chart dependencies to make sure all are present in /charts
+ if chartDependencies := chartReq.Metadata.Dependencies; chartDependencies != nil {
+ if err := action.CheckDependencies(chartReq, chartDependencies); err != nil {
+ err = errors.Wrap(err, "failed to check chart dependencies for helm release installation")
+ if !installClient.DependencyUpdate {
+ return nil, err
+ }
+
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("chart", installOpts.Chart).
+ Msg("Updating chart dependencies for helm release installation")
+
+ providers := getter.All(hspm.settings)
+ manager := &downloader.Manager{
+ Out: os.Stdout,
+ ChartPath: chartPath,
+ Keyring: installClient.ChartPathOptions.Keyring,
+ SkipUpdate: false,
+ Getters: providers,
+ RepositoryConfig: hspm.settings.RepositoryConfig,
+ RepositoryCache: hspm.settings.RepositoryCache,
+ Debug: hspm.settings.Debug,
+ }
+ if err := manager.Update(); err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart", installOpts.Chart).
+ Err(err).
+ Msg("Failed to update chart dependencies for helm release installation")
+ return nil, errors.Wrap(err, "failed to update chart dependencies for helm release installation")
+ }
+
+ // Reload the chart with the updated Chart.lock file.
+ if chartReq, err = loader.Load(chartPath); err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart_path", chartPath).
+ Err(err).
+ Msg("Failed to reload chart after dependency update for helm release installation")
+ return nil, errors.Wrap(err, "failed to reload chart after dependency update for helm release installation")
+ }
+ }
+ }
+
+ return chartReq, nil
+}
+
+// initInstallClient initializes the install client with the given options
+// and return the install client.
+func initInstallClient(actionConfig *action.Configuration, installOpts options.InstallOptions) (*action.Install, error) {
+ installClient := action.NewInstall(actionConfig)
+ installClient.CreateNamespace = true
+ installClient.DependencyUpdate = true
+
+ installClient.ReleaseName = installOpts.Name
+ installClient.Namespace = installOpts.Namespace
+ installClient.ChartPathOptions.RepoURL = installOpts.Repo
+ installClient.Wait = installOpts.Wait
+ if installOpts.PostRenderer != "" {
+ postRenderer, err := postrender.NewExec(installOpts.PostRenderer)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to create post renderer")
+ }
+ installClient.PostRenderer = postRenderer
+ }
+
+ return installClient, nil
+}
diff --git a/pkg/libhelm/sdk/install_test.go b/pkg/libhelm/sdk/install_test.go
new file mode 100644
index 000000000..9bd0d04f3
--- /dev/null
+++ b/pkg/libhelm/sdk/install_test.go
@@ -0,0 +1,190 @@
+package sdk
+
+import (
+ "os"
+ "testing"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/test"
+ "github.com/stretchr/testify/assert"
+)
+
+func createValuesFile(values string) (string, error) {
+ file, err := os.CreateTemp("", "helm-values")
+ if err != nil {
+ return "", err
+ }
+
+ _, err = file.WriteString(values)
+ if err != nil {
+ file.Close()
+ return "", err
+ }
+
+ err = file.Close()
+ if err != nil {
+ return "", err
+ }
+
+ return file.Name(), nil
+}
+
+func Test_Install(t *testing.T) {
+ test.EnsureIntegrationTest(t)
+ is := assert.New(t)
+
+ // Create a new SDK package manager
+ hspm := NewHelmSDKPackageManager()
+
+ t.Run("successfully installs nginx chart with name test-nginx", func(t *testing.T) {
+ // SDK equivalent of: helm install test-nginx --repo https://kubernetes.github.io/ingress-nginx nginx
+ installOpts := options.InstallOptions{
+ Name: "test-nginx",
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ }
+
+ release, err := hspm.Install(installOpts)
+ if release != nil {
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: "test-nginx",
+ })
+ }
+
+ is.NoError(err, "should successfully install release")
+ is.NotNil(release, "should return non-nil release")
+ is.Equal("test-nginx", release.Name, "release name should match")
+ is.Equal(1, release.Version, "release version should be 1")
+ is.NotEmpty(release.Manifest, "release manifest should not be empty")
+ })
+
+ t.Run("successfully installs nginx with values", func(t *testing.T) {
+ // SDK equivalent of: helm install test-nginx-2 --repo https://kubernetes.github.io/ingress-nginx nginx --values /tmp/helm-values3161785816
+ values, err := createValuesFile("service:\n port: 8081")
+ is.NoError(err, "should create a values file")
+
+ defer os.Remove(values)
+
+ installOpts := options.InstallOptions{
+ Name: "test-nginx-2",
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ ValuesFile: values,
+ }
+ release, err := hspm.Install(installOpts)
+ if release != nil {
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: "test-nginx-2",
+ })
+ }
+
+ is.NoError(err, "should successfully install release")
+ is.NotNil(release, "should return non-nil release")
+ is.Equal("test-nginx-2", release.Name, "release name should match")
+ is.Equal(1, release.Version, "release version should be 1")
+ is.NotEmpty(release.Manifest, "release manifest should not be empty")
+ })
+
+ t.Run("successfully installs portainer chart with name portainer-test", func(t *testing.T) {
+ // SDK equivalent of: helm install portainer-test portainer --repo https://portainer.github.io/k8s/
+ installOpts := options.InstallOptions{
+ Name: "portainer-test",
+ Chart: "portainer",
+ Repo: "https://portainer.github.io/k8s/",
+ }
+ release, err := hspm.Install(installOpts)
+ if release != nil {
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+ }
+
+ is.NoError(err, "should successfully install release")
+ is.NotNil(release, "should return non-nil release")
+ is.Equal("portainer-test", release.Name, "release name should match")
+ is.Equal(1, release.Version, "release version should be 1")
+ is.NotEmpty(release.Manifest, "release manifest should not be empty")
+ })
+
+ t.Run("install with values as string", func(t *testing.T) {
+ // First create a values file since InstallOptions doesn't support values as string directly
+ values, err := createValuesFile("service:\n port: 8082")
+ is.NoError(err, "should create a values file")
+
+ defer os.Remove(values)
+
+ // Install with values file
+ installOpts := options.InstallOptions{
+ Name: "test-nginx-3",
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ ValuesFile: values,
+ }
+ release, err := hspm.Install(installOpts)
+ if release != nil {
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: "test-nginx-3",
+ })
+ }
+
+ is.NoError(err, "should successfully install release")
+ is.NotNil(release, "should return non-nil release")
+ is.Equal("test-nginx-3", release.Name, "release name should match")
+ })
+
+ t.Run("install with namespace", func(t *testing.T) {
+ // Install with namespace
+ installOpts := options.InstallOptions{
+ Name: "test-nginx-4",
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ Namespace: "default",
+ }
+ release, err := hspm.Install(installOpts)
+ if release != nil {
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: "test-nginx-4",
+ Namespace: "default",
+ })
+ }
+
+ is.NoError(err, "should successfully install release")
+ is.NotNil(release, "should return non-nil release")
+ is.Equal("test-nginx-4", release.Name, "release name should match")
+ is.Equal("default", release.Namespace, "release namespace should match")
+ })
+
+ t.Run("returns an error when name is not provided", func(t *testing.T) {
+ installOpts := options.InstallOptions{
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ }
+ _, err := hspm.Install(installOpts)
+
+ is.Error(err, "should return an error when name is not provided")
+ is.Equal(err.Error(), "name is required")
+ })
+
+ t.Run("install with invalid chart", func(t *testing.T) {
+ // Install with invalid chart
+ installOpts := options.InstallOptions{
+ Name: "test-invalid",
+ Chart: "non-existent-chart",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ }
+ _, err := hspm.Install(installOpts)
+ is.Error(err, "should return error when chart doesn't exist")
+ is.Equal(err.Error(), "failed to find the helm chart at the path: https://kubernetes.github.io/ingress-nginx/non-existent-chart")
+ })
+
+ t.Run("install with invalid repo", func(t *testing.T) {
+ // Install with invalid repo
+ installOpts := options.InstallOptions{
+ Name: "test-invalid-repo",
+ Chart: "nginx",
+ Repo: "https://non-existent-repo.example.com",
+ }
+ _, err := hspm.Install(installOpts)
+ is.Error(err, "should return error when repo doesn't exist")
+ })
+}
diff --git a/pkg/libhelm/sdk/list.go b/pkg/libhelm/sdk/list.go
new file mode 100644
index 000000000..494298a91
--- /dev/null
+++ b/pkg/libhelm/sdk/list.go
@@ -0,0 +1,108 @@
+package sdk
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/pkg/errors"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/rs/zerolog/log"
+ "helm.sh/helm/v3/pkg/action"
+ sdkrelease "helm.sh/helm/v3/pkg/release"
+)
+
+// List implements the HelmPackageManager interface by using the Helm SDK to list releases.
+// It returns a slice of ReleaseElement.
+func (hspm *HelmSDKPackageManager) List(listOpts options.ListOptions) ([]release.ReleaseElement, error) {
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("namespace", listOpts.Namespace).
+ Str("filter", listOpts.Filter).
+ Str("selector", listOpts.Selector).
+ Msg("Listing Helm releases")
+
+ // Initialize action configuration with kubernetes config
+ actionConfig := new(action.Configuration)
+ err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("namespace", listOpts.Namespace).
+ Err(err).
+ Msg("Failed to initialize helm configuration")
+ return nil, errors.Wrap(err, "failed to initialize helm configuration")
+ }
+
+ listClient, err := initListClient(actionConfig, listOpts)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to initialize helm list client")
+ }
+
+ // Run the list operation
+ releases, err := listClient.Run()
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to list helm releases")
+ return []release.ReleaseElement{}, errors.Wrap(err, "failed to list helm releases")
+ }
+
+ // Convert from SDK release type to our release element type and return
+ return convertToReleaseElements(releases), nil
+}
+
+// convertToReleaseElements converts from the SDK release type to our release element type
+func convertToReleaseElements(releases []*sdkrelease.Release) []release.ReleaseElement {
+ elements := make([]release.ReleaseElement, len(releases))
+
+ for i, rel := range releases {
+ chartName := fmt.Sprintf("%s-%s", rel.Chart.Metadata.Name, rel.Chart.Metadata.Version)
+
+ elements[i] = release.ReleaseElement{
+ Name: rel.Name,
+ Namespace: rel.Namespace,
+ Revision: strconv.Itoa(rel.Version),
+ Updated: rel.Info.LastDeployed.String(),
+ Status: string(rel.Info.Status),
+ Chart: chartName,
+ AppVersion: rel.Chart.Metadata.AppVersion,
+ }
+ }
+
+ return elements
+}
+
+// initListClient initializes the list client with the given options
+// and return the list client.
+func initListClient(actionConfig *action.Configuration, listOpts options.ListOptions) (*action.List, error) {
+ listClient := action.NewList(actionConfig)
+
+ // Configure list options
+ if listOpts.Filter != "" {
+ listClient.Filter = listOpts.Filter
+ }
+
+ if listOpts.Selector != "" {
+ listClient.Selector = listOpts.Selector
+ }
+
+ // If no namespace is specified in options, list across all namespaces
+ if listOpts.Namespace == "" {
+ listClient.AllNamespaces = true
+ }
+
+ // No limit by default
+ listClient.Limit = 0
+ // Show all releases, even if in a pending or failed state
+ listClient.All = true
+
+ // Set state mask to ensure proper filtering by status
+ listClient.SetStateMask()
+
+ return listClient, nil
+}
diff --git a/pkg/libhelm/sdk/list_test.go b/pkg/libhelm/sdk/list_test.go
new file mode 100644
index 000000000..c2f0ac43c
--- /dev/null
+++ b/pkg/libhelm/sdk/list_test.go
@@ -0,0 +1,72 @@
+package sdk
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "helm.sh/helm/v3/pkg/chart"
+ "helm.sh/helm/v3/pkg/release"
+ "helm.sh/helm/v3/pkg/time"
+)
+
+func Test_ConvertToReleaseElements(t *testing.T) {
+ is := assert.New(t)
+
+ // Create mock releases
+ releases := []*release.Release{
+ {
+ Name: "release1",
+ Namespace: "default",
+ Version: 1,
+ Info: &release.Info{
+ Status: release.StatusDeployed,
+ LastDeployed: time.Now(),
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "chart1",
+ Version: "1.0.0",
+ AppVersion: "1.0.0",
+ },
+ },
+ },
+ {
+ Name: "release2",
+ Namespace: "kube-system",
+ Version: 2,
+ Info: &release.Info{
+ Status: release.StatusFailed,
+ LastDeployed: time.Now(),
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "chart2",
+ Version: "2.0.0",
+ AppVersion: "2.0.0",
+ },
+ },
+ },
+ }
+
+ // Convert to release elements
+ elements := convertToReleaseElements(releases)
+
+ // Verify conversion
+ is.Len(elements, 2, "should return 2 release elements")
+
+ // Verify first release
+ is.Equal("release1", elements[0].Name, "first release name should be release1")
+ is.Equal("default", elements[0].Namespace, "first release namespace should be default")
+ is.Equal("1", elements[0].Revision, "first release revision should be 1")
+ is.Equal(string(release.StatusDeployed), elements[0].Status, "first release status should be deployed")
+ is.Equal("chart1-1.0.0", elements[0].Chart, "first release chart should be chart1-1.0.0")
+ is.Equal("1.0.0", elements[0].AppVersion, "first release app version should be 1.0.0")
+
+ // Verify second release
+ is.Equal("release2", elements[1].Name, "second release name should be release2")
+ is.Equal("kube-system", elements[1].Namespace, "second release namespace should be kube-system")
+ is.Equal("2", elements[1].Revision, "second release revision should be 2")
+ is.Equal(string(release.StatusFailed), elements[1].Status, "second release status should be failed")
+ is.Equal("chart2-2.0.0", elements[1].Chart, "second release chart should be chart2-2.0.0")
+ is.Equal("2.0.0", elements[1].AppVersion, "second release app version should be 2.0.0")
+}
diff --git a/pkg/libhelm/sdk/manager.go b/pkg/libhelm/sdk/manager.go
new file mode 100644
index 000000000..8652dd724
--- /dev/null
+++ b/pkg/libhelm/sdk/manager.go
@@ -0,0 +1,23 @@
+package sdk
+
+import (
+ "time"
+
+ "github.com/portainer/portainer/pkg/libhelm/types"
+ "helm.sh/helm/v3/pkg/cli"
+)
+
+// HelmSDKPackageManager is a wrapper for the helm SDK which implements HelmPackageManager
+type HelmSDKPackageManager struct {
+ settings *cli.EnvSettings
+ timeout time.Duration
+}
+
+// NewHelmSDKPackageManager initializes a new HelmPackageManager service using the Helm SDK
+func NewHelmSDKPackageManager() types.HelmPackageManager {
+ settings := cli.New()
+ return &HelmSDKPackageManager{
+ settings: settings,
+ timeout: 300 * time.Second, // 5 minutes default timeout
+ }
+}
diff --git a/pkg/libhelm/sdk/manager_test.go b/pkg/libhelm/sdk/manager_test.go
new file mode 100644
index 000000000..018794501
--- /dev/null
+++ b/pkg/libhelm/sdk/manager_test.go
@@ -0,0 +1,29 @@
+package sdk
+
+import (
+ "testing"
+ "time"
+
+ "github.com/portainer/portainer/pkg/libhelm/types"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_NewHelmSDKPackageManager(t *testing.T) {
+ is := assert.New(t)
+
+ // Test that NewHelmSDKPackageManager returns a non-nil HelmPackageManager
+ manager := NewHelmSDKPackageManager()
+ is.NotNil(manager, "should return non-nil HelmPackageManager")
+
+ // Test that the returned manager is of the correct type
+ _, ok := manager.(*HelmSDKPackageManager)
+ is.True(ok, "should return a *HelmSDKPackageManager")
+
+ // Test that the manager has the expected fields
+ sdkManager := manager.(*HelmSDKPackageManager)
+ is.NotNil(sdkManager.settings, "should have non-nil settings")
+ is.Equal(300*time.Second, sdkManager.timeout, "should have 5 minute timeout")
+
+ // Test that the manager implements the HelmPackageManager interface
+ var _ types.HelmPackageManager = manager
+}
diff --git a/pkg/libhelm/sdk/search_repo.go b/pkg/libhelm/sdk/search_repo.go
new file mode 100644
index 000000000..90dd98529
--- /dev/null
+++ b/pkg/libhelm/sdk/search_repo.go
@@ -0,0 +1,351 @@
+package sdk
+
+import (
+ "net/url"
+ "os"
+ "path/filepath"
+
+ "github.com/pkg/errors"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/rs/zerolog/log"
+ "github.com/segmentio/encoding/json"
+ "helm.sh/helm/v3/pkg/cli"
+ "helm.sh/helm/v3/pkg/getter"
+ "helm.sh/helm/v3/pkg/repo"
+)
+
+var (
+ errRequiredSearchOptions = errors.New("repo is required")
+ errInvalidRepoURL = errors.New("the request failed since either the Helm repository was not found or the index.yaml is not valid")
+)
+
+type RepoIndex struct {
+ APIVersion string `json:"apiVersion"`
+ Entries map[string][]ChartInfo `json:"entries"`
+ Generated string `json:"generated"`
+}
+
+// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller.
+func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
+ // Validate input options
+ if err := validateSearchRepoOptions(searchRepoOpts); err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("repo", searchRepoOpts.Repo).
+ Err(err).
+ Msg("Missing required search repo options")
+ return nil, err
+ }
+
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("repo", searchRepoOpts.Repo).
+ Msg("Searching repository")
+
+ // Parse and validate the repository URL
+ repoURL, err := parseRepoURL(searchRepoOpts.Repo)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("repo", searchRepoOpts.Repo).
+ Err(err).
+ Msg("Invalid repository URL")
+ return nil, err
+ }
+
+ // Set up Helm CLI environment
+ repoSettings := cli.New()
+
+ // Ensure all required Helm directories exist
+ if err := ensureHelmDirectoriesExist(repoSettings); err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to ensure Helm directories exist")
+ return nil, errors.Wrap(err, "failed to ensure Helm directories exist")
+ }
+
+ // Download the index file and update repository configuration
+ indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, searchRepoOpts.Repo)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("repo_url", repoURL.String()).
+ Err(err).
+ Msg("Failed to download repository index")
+ return nil, err
+ }
+
+ // Load and parse the index file
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("index_path", indexPath).
+ Msg("Loading index file")
+
+ indexFile, err := loadIndexFile(indexPath)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("index_path", indexPath).
+ Err(err).
+ Msg("Failed to load index file")
+ return nil, err
+ }
+
+ // Convert the index file to our response format
+ result, err := convertIndexToResponse(indexFile)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to convert index to response format")
+ return nil, errors.Wrap(err, "failed to convert index to response format")
+ }
+
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("repo", searchRepoOpts.Repo).
+ Int("entries_count", len(indexFile.Entries)).
+ Msg("Successfully searched repository")
+
+ return json.Marshal(result)
+}
+
+// validateSearchRepoOptions validates the required search repository options.
+func validateSearchRepoOptions(opts options.SearchRepoOptions) error {
+ if opts.Repo == "" {
+ return errRequiredSearchOptions
+ }
+ return nil
+}
+
+// parseRepoURL parses and validates the repository URL.
+func parseRepoURL(repoURL string) (*url.URL, error) {
+ parsedURL, err := url.ParseRequestURI(repoURL)
+ if err != nil {
+ return nil, errors.Wrap(err, "invalid helm chart URL: "+repoURL)
+ }
+ return parsedURL, nil
+}
+
+// downloadRepoIndex downloads the index.yaml file from the repository and updates
+// the repository configuration.
+func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repoName string) (string, error) {
+ log.Debug().
+ Str("context", "helm_sdk_repo_index").
+ Str("repo_url", repoURLString).
+ Str("repo_name", repoName).
+ Msg("Creating chart repository object")
+
+ // Create chart repository object
+ rep, err := repo.NewChartRepository(
+ &repo.Entry{
+ URL: repoURLString,
+ },
+ getter.All(repoSettings),
+ )
+ if err != nil {
+ log.Error().
+ Str("context", "helm_sdk_repo_index").
+ Str("repo_url", repoURLString).
+ Err(err).
+ Msg("Failed to create chart repository object")
+ return "", errInvalidRepoURL
+ }
+
+ // Load repository configuration file
+ f, err := repo.LoadFile(repoSettings.RepositoryConfig)
+ if err != nil {
+ log.Error().
+ Str("context", "helm_sdk_repo_index").
+ Str("repo_config", repoSettings.RepositoryConfig).
+ Err(err).
+ Msg("Failed to load repo config")
+ return "", errors.Wrap(err, "failed to load repo config")
+ }
+
+ // Download the index file
+ log.Debug().
+ Str("context", "helm_sdk_repo_index").
+ Str("repo_url", repoURLString).
+ Msg("Downloading index file")
+
+ indexPath, err := rep.DownloadIndexFile()
+ if err != nil {
+ log.Error().
+ Str("context", "helm_sdk_repo_index").
+ Str("repo_url", repoURLString).
+ Err(err).
+ Msg("Failed to download index file")
+ return "", errors.Wrapf(err, "looks like %q is not a valid chart repository or cannot be reached", repoURLString)
+ }
+
+ // Update repository configuration
+ c := repo.Entry{
+ Name: repoName,
+ URL: repoURLString,
+ }
+ f.Update(&c)
+
+ // Write updated configuration
+ repoFile := repoSettings.RepositoryConfig
+ if err := f.WriteFile(repoFile, 0644); err != nil {
+ log.Error().
+ Str("context", "helm_sdk_repo_index").
+ Str("repo_file", repoSettings.RepositoryConfig).
+ Err(err).
+ Msg("Failed to write repository configuration")
+ return "", errors.Wrap(err, "failed to write repository configuration")
+ }
+
+ log.Debug().
+ Str("context", "helm_sdk_repo_index").
+ Str("index_path", indexPath).
+ Msg("Successfully downloaded index file")
+
+ return indexPath, nil
+}
+
+// loadIndexFile loads the index file from the given path.
+func loadIndexFile(indexPath string) (*repo.IndexFile, error) {
+ indexFile, err := repo.LoadIndexFile(indexPath)
+ if err != nil {
+ return nil, errors.Wrapf(err, "failed to load downloaded index file: %s", indexPath)
+ }
+ return indexFile, nil
+}
+
+// convertIndexToResponse converts the Helm index file to our response format.
+func convertIndexToResponse(indexFile *repo.IndexFile) (RepoIndex, error) {
+ result := RepoIndex{
+ APIVersion: indexFile.APIVersion,
+ Entries: make(map[string][]ChartInfo),
+ Generated: indexFile.Generated.String(),
+ }
+
+ // Convert Helm SDK types to our response types
+ for name, charts := range indexFile.Entries {
+ result.Entries[name] = convertChartsToChartInfo(charts)
+ }
+
+ return result, nil
+}
+
+// convertChartsToChartInfo converts Helm chart entries to ChartInfo objects.
+func convertChartsToChartInfo(charts []*repo.ChartVersion) []ChartInfo {
+ chartInfos := make([]ChartInfo, len(charts))
+ for i, chart := range charts {
+ chartInfos[i] = ChartInfo{
+ Name: chart.Name,
+ Version: chart.Version,
+ AppVersion: chart.AppVersion,
+ Description: chart.Description,
+ Deprecated: chart.Deprecated,
+ Created: chart.Created.String(),
+ Digest: chart.Digest,
+ Home: chart.Home,
+ Sources: chart.Sources,
+ URLs: chart.URLs,
+ Icon: chart.Icon,
+ Annotations: chart.Annotations,
+ }
+ }
+ return chartInfos
+}
+
+// ChartInfo represents a Helm chart in the repository index
+type ChartInfo struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ AppVersion string `json:"appVersion"`
+ Description string `json:"description"`
+ Deprecated bool `json:"deprecated"`
+ Created string `json:"created"`
+ Digest string `json:"digest"`
+ Home string `json:"home"`
+ Sources []string `json:"sources"`
+ URLs []string `json:"urls"`
+ Icon string `json:"icon,omitempty"`
+ Annotations any `json:"annotations,omitempty"`
+}
+
+// ensureHelmDirectoriesExist checks and creates required Helm directories if they don't exist
+func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
+ log.Debug().
+ Str("context", "helm_sdk_dirs").
+ Msg("Ensuring Helm directories exist")
+
+ // List of directories to ensure exist
+ directories := []string{
+ filepath.Dir(settings.RepositoryConfig), // Repository config directory
+ settings.RepositoryCache, // Repository cache directory
+ filepath.Dir(settings.RegistryConfig), // Registry config directory
+ settings.PluginsDirectory, // Plugins directory
+ }
+
+ // Create each directory if it doesn't exist
+ for _, dir := range directories {
+ if dir == "" {
+ continue // Skip empty paths
+ }
+
+ if _, err := os.Stat(dir); os.IsNotExist(err) {
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ log.Error().
+ Str("context", "helm_sdk_dirs").
+ Str("directory", dir).
+ Err(err).
+ Msg("Failed to create directory")
+ return errors.Wrapf(err, "failed to create directory: %s", dir)
+ }
+ }
+ }
+
+ // Ensure registry config file exists
+ if settings.RegistryConfig != "" {
+ if _, err := os.Stat(settings.RegistryConfig); os.IsNotExist(err) {
+ // Create the directory if it doesn't exist
+ dir := filepath.Dir(settings.RegistryConfig)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ log.Error().
+ Str("context", "helm_sdk_dirs").
+ Str("directory", dir).
+ Err(err).
+ Msg("Failed to create directory")
+ return errors.Wrapf(err, "failed to create directory: %s", dir)
+ }
+
+ // Create an empty registry config file
+ if _, err := os.Create(settings.RegistryConfig); err != nil {
+ log.Error().
+ Str("context", "helm_sdk_dirs").
+ Str("file", settings.RegistryConfig).
+ Err(err).
+ Msg("Failed to create registry config file")
+ return errors.Wrapf(err, "failed to create registry config file: %s", settings.RegistryConfig)
+ }
+ }
+ }
+
+ // Ensure repository config file exists
+ if settings.RepositoryConfig != "" {
+ if _, err := os.Stat(settings.RepositoryConfig); os.IsNotExist(err) {
+ // Create an empty repository config file with default yaml structure
+ f := repo.NewFile()
+ if err := f.WriteFile(settings.RepositoryConfig, 0644); err != nil {
+ log.Error().
+ Str("context", "helm_sdk_dirs").
+ Str("file", settings.RepositoryConfig).
+ Err(err).
+ Msg("Failed to create repository config file")
+ return errors.Wrapf(err, "failed to create repository config file: %s", settings.RepositoryConfig)
+ }
+ }
+ }
+
+ log.Debug().
+ Str("context", "helm_sdk_dirs").
+ Msg("Successfully ensured all Helm directories exist")
+
+ return nil
+}
diff --git a/pkg/libhelm/sdk/search_repo_test.go b/pkg/libhelm/sdk/search_repo_test.go
new file mode 100644
index 000000000..d4cfd9b46
--- /dev/null
+++ b/pkg/libhelm/sdk/search_repo_test.go
@@ -0,0 +1,84 @@
+package sdk
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/stretchr/testify/assert"
+)
+
+type testCase struct {
+ name string
+ url string
+ invalid bool
+}
+
+var tests = []testCase{
+ {"not a helm repo", "https://portainer.io", true},
+ {"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", false},
+ {"portainer helm repo", "https://portainer.github.io/k8s/", false},
+ {"elastic helm repo with trailing slash", "https://helm.elastic.co/", false},
+ {"lensesio helm repo without trailing slash", "https://lensesio.github.io/kafka-helm-charts", false},
+}
+
+func Test_SearchRepo(t *testing.T) {
+ is := assert.New(t)
+
+ // Create a new SDK package manager
+ hspm := NewHelmSDKPackageManager()
+
+ for _, test := range tests {
+ func(tc testCase) {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ response, err := hspm.SearchRepo(options.SearchRepoOptions{Repo: tc.url})
+ if tc.invalid {
+ is.Errorf(err, "error expected: %s", tc.url)
+ } else {
+ is.NoError(err, "no error expected: %s", tc.url)
+ }
+
+ if err == nil {
+ is.NotEmpty(response, "response expected")
+ }
+ })
+ }(test)
+ }
+
+ t.Run("search repo with keyword", func(t *testing.T) {
+ // Search for charts with keyword
+ searchOpts := options.SearchRepoOptions{
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ }
+ responseBytes, err := hspm.SearchRepo(searchOpts)
+
+ // The function should not fail by design, even when not running in a k8s environment
+ is.NoError(err, "should not return error when not in k8s environment")
+ is.NotNil(responseBytes, "should return non-nil response")
+ is.NotEmpty(responseBytes, "should return non-empty response")
+
+ // Parse the ext response
+ var repoIndex RepoIndex
+ err = json.Unmarshal(responseBytes, &repoIndex)
+ is.NoError(err, "should parse JSON response without error")
+ is.NotEmpty(repoIndex, "should have at least one chart")
+
+ // Verify charts structure apiVersion, entries, generated
+ is.Equal("v1", repoIndex.APIVersion, "apiVersion should be v1")
+ is.NotEmpty(repoIndex.Entries, "entries should not be empty")
+ is.NotEmpty(repoIndex.Generated, "generated should not be empty")
+
+ // there should be at least one chart
+ is.Greater(len(repoIndex.Entries), 0, "should have at least one chart")
+ })
+
+ t.Run("search repo with empty repo URL", func(t *testing.T) {
+ // Search with empty repo URL
+ searchOpts := options.SearchRepoOptions{
+ Repo: "",
+ }
+ _, err := hspm.SearchRepo(searchOpts)
+ is.Error(err, "should return error when repo URL is empty")
+ })
+}
diff --git a/pkg/libhelm/sdk/show.go b/pkg/libhelm/sdk/show.go
new file mode 100644
index 000000000..aa674a8c0
--- /dev/null
+++ b/pkg/libhelm/sdk/show.go
@@ -0,0 +1,131 @@
+package sdk
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/pkg/errors"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/rs/zerolog/log"
+ "helm.sh/helm/v3/pkg/action"
+)
+
+var errRequiredShowOptions = errors.New("chart, repo and output format are required")
+
+// Show implements the HelmPackageManager interface by using the Helm SDK to show chart information.
+// It supports showing chart values, readme, and chart details based on the provided ShowOptions.
+func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
+ if showOpts.Chart == "" || showOpts.Repo == "" || showOpts.OutputFormat == "" {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart", showOpts.Chart).
+ Str("repo", showOpts.Repo).
+ Str("output_format", string(showOpts.OutputFormat)).
+ Msg("Missing required show options")
+ return nil, errRequiredShowOptions
+ }
+
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("chart", showOpts.Chart).
+ Str("repo", showOpts.Repo).
+ Str("output_format", string(showOpts.OutputFormat)).
+ Msg("Showing chart information")
+
+ // Initialize action configuration
+ actionConfig := new(action.Configuration)
+ if err := actionConfig.Init(nil, "", "", func(format string, v ...interface{}) {}); err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to initialize helm configuration")
+ return nil, fmt.Errorf("failed to initialize helm configuration: %w", err)
+ }
+
+ // Create temporary directory for chart download
+ tempDir, err := os.MkdirTemp("", "helm-show-*")
+ 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)
+ }
+ defer os.RemoveAll(tempDir)
+
+ // Create showClient action
+ showClient, err := initShowClient(actionConfig, showOpts)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to initialize helm show client")
+ return nil, fmt.Errorf("failed to initialize helm show client: %w", err)
+ }
+
+ // Locate and load the chart
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("chart", showOpts.Chart).
+ Str("repo", showOpts.Repo).
+ Msg("Locating chart")
+
+ chartPath, err := showClient.ChartPathOptions.LocateChart(showOpts.Chart, hspm.settings)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart", showOpts.Chart).
+ Str("repo", showOpts.Repo).
+ Err(err).
+ Msg("Failed to locate chart")
+ return nil, fmt.Errorf("failed to locate chart: %w", err)
+ }
+
+ // Get the output based on the requested format
+ output, err := showClient.Run(chartPath)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart_path", chartPath).
+ Str("output_format", string(showOpts.OutputFormat)).
+ Err(err).
+ Msg("Failed to show chart info")
+ return nil, fmt.Errorf("failed to show chart info: %w", err)
+ }
+
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("chart", showOpts.Chart).
+ Int("output_size", len(output)).
+ Msg("Successfully retrieved chart information")
+
+ return []byte(output), nil
+}
+
+// initShowClient initializes the show client with the given options
+// 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 = "" // Latest 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:
+ showClient.OutputFormat = action.ShowValues
+ case options.ShowReadme:
+ showClient.OutputFormat = action.ShowReadme
+ default:
+ log.Error().
+ Str("context", "HelmClient").
+ Str("output_format", string(showOpts.OutputFormat)).
+ Msg("Unsupported output format")
+ return nil, fmt.Errorf("unsupported output format: %s", showOpts.OutputFormat)
+ }
+
+ return showClient, nil
+}
diff --git a/pkg/libhelm/sdk/show_test.go b/pkg/libhelm/sdk/show_test.go
new file mode 100644
index 000000000..124aac02d
--- /dev/null
+++ b/pkg/libhelm/sdk/show_test.go
@@ -0,0 +1,102 @@
+package sdk
+
+import (
+ "testing"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/test"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_Show(t *testing.T) {
+ test.EnsureIntegrationTest(t)
+ is := assert.New(t)
+
+ // Create a new SDK package manager
+ hspm := NewHelmSDKPackageManager()
+
+ // install the ingress-nginx chart to test the show command
+ installOpts := options.InstallOptions{
+ Name: "ingress-nginx",
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ }
+ release, err := hspm.Install(installOpts)
+ if release != nil || err != nil {
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: "ingress-nginx",
+ })
+ }
+
+ t.Run("show requires chart, repo and output format", func(t *testing.T) {
+ showOpts := options.ShowOptions{
+ Chart: "",
+ Repo: "",
+ OutputFormat: "",
+ }
+ _, err := hspm.Show(showOpts)
+ is.Error(err, "should return error when required options are missing")
+ is.Contains(err.Error(), "chart, repo and output format are required", "error message should indicate required options")
+ })
+
+ t.Run("show chart values", func(t *testing.T) {
+ showOpts := options.ShowOptions{
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ OutputFormat: options.ShowValues,
+ }
+ values, err := hspm.Show(showOpts)
+
+ is.NoError(err, "should not return error when not in k8s environment")
+ is.NotEmpty(values, "should return non-empty values")
+ })
+
+ t.Run("show chart readme", func(t *testing.T) {
+ showOpts := options.ShowOptions{
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ OutputFormat: options.ShowReadme,
+ }
+ readme, err := hspm.Show(showOpts)
+
+ is.NoError(err, "should not return error when not in k8s environment")
+ is.NotEmpty(readme, "should return non-empty readme")
+ })
+
+ t.Run("show chart definition", func(t *testing.T) {
+ showOpts := options.ShowOptions{
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ OutputFormat: options.ShowChart,
+ }
+ chart, err := hspm.Show(showOpts)
+
+ is.NoError(err, "should not return error when not in k8s environment")
+ is.NotNil(chart, "should return non-nil chart definition")
+ })
+
+ t.Run("show all chart info", func(t *testing.T) {
+ showOpts := options.ShowOptions{
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ OutputFormat: options.ShowAll,
+ }
+ info, err := hspm.Show(showOpts)
+
+ is.NoError(err, "should not return error when not in k8s environment")
+ is.NotEmpty(info, "should return non-empty chart info")
+ })
+
+ t.Run("show with invalid output format", func(t *testing.T) {
+ // Show with invalid output format
+ showOpts := options.ShowOptions{
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ OutputFormat: "invalid",
+ }
+ _, err := hspm.Show(showOpts)
+
+ is.Error(err, "should return error with invalid output format")
+ is.Contains(err.Error(), "unsupported output format", "error message should indicate invalid output format")
+ })
+}
diff --git a/pkg/libhelm/sdk/uninstall.go b/pkg/libhelm/sdk/uninstall.go
new file mode 100644
index 000000000..5459b9e84
--- /dev/null
+++ b/pkg/libhelm/sdk/uninstall.go
@@ -0,0 +1,70 @@
+package sdk
+
+import (
+ "github.com/pkg/errors"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/rs/zerolog/log"
+ "helm.sh/helm/v3/pkg/action"
+)
+
+// Uninstall implements the HelmPackageManager interface by using the Helm SDK to uninstall a release.
+func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
+ if uninstallOpts.Name == "" {
+ log.Error().
+ Str("context", "HelmClient").
+ Msg("Release name is required")
+ return errors.New("release name is required")
+ }
+
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("release", uninstallOpts.Name).
+ Str("namespace", uninstallOpts.Namespace).
+ Msg("Uninstalling Helm release")
+
+ // Initialize action configuration with kubernetes config
+ actionConfig := new(action.Configuration)
+ err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("release", uninstallOpts.Name).
+ Str("namespace", uninstallOpts.Namespace).
+ Err(err).
+ Msg("Failed to initialize helm configuration")
+ return errors.Wrap(err, "failed to initialize helm configuration")
+ }
+
+ // Create uninstallClient action
+ uninstallClient := action.NewUninstall(actionConfig)
+ // 'foreground' means the parent object remains in a "terminating" state until all of its children are deleted. This ensures that all dependent resources are completely removed before finalizing the deletion of the parent resource.
+ uninstallClient.DeletionPropagation = "foreground" // "background" or "orphan"
+
+ // Run the uninstallation
+ log.Info().
+ Str("context", "HelmClient").
+ Str("release", uninstallOpts.Name).
+ Str("namespace", uninstallOpts.Namespace).
+ Msg("Running uninstallation")
+
+ result, err := uninstallClient.Run(uninstallOpts.Name)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("release", uninstallOpts.Name).
+ Str("namespace", uninstallOpts.Namespace).
+ Err(err).
+ Msg("Failed to uninstall helm release")
+ return errors.Wrap(err, "failed to uninstall helm release")
+ }
+
+ if result != nil {
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("release", uninstallOpts.Name).
+ Str("release_info", result.Release.Info.Description).
+ Msg("Uninstall result details")
+ }
+
+ return nil
+}
diff --git a/pkg/libhelm/sdk/uninstall_test.go b/pkg/libhelm/sdk/uninstall_test.go
new file mode 100644
index 000000000..28ef1cb21
--- /dev/null
+++ b/pkg/libhelm/sdk/uninstall_test.go
@@ -0,0 +1,65 @@
+package sdk
+
+import (
+ "testing"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/test"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_Uninstall(t *testing.T) {
+ test.EnsureIntegrationTest(t)
+ is := assert.New(t)
+
+ // Create a new SDK package manager
+ hspm := NewHelmSDKPackageManager()
+
+ t.Run("uninstall requires a release name", func(t *testing.T) {
+ // Try to uninstall without a release name
+ uninstallOpts := options.UninstallOptions{
+ Name: "",
+ }
+ err := hspm.Uninstall(uninstallOpts)
+ is.Error(err, "should return error when release name is empty")
+ is.Contains(err.Error(), "release name is required", "error message should indicate release name is required")
+ })
+
+ t.Run("uninstall a non-existent release", func(t *testing.T) {
+ // Try to uninstall a release that doesn't exist
+ uninstallOpts := options.UninstallOptions{
+ Name: "non-existent-release",
+ }
+ err := hspm.Uninstall(uninstallOpts)
+
+ // The function should not fail by design, even when not running in a k8s environment
+ // However, it should return an error for a non-existent release
+ is.Error(err, "should return error when release doesn't exist")
+ is.Contains(err.Error(), "not found", "error message should indicate release not found")
+ })
+
+ // This test is commented out as it requires a real release to be installed first
+ t.Run("successfully uninstall an existing release", func(t *testing.T) {
+ // First install a release
+ installOpts := options.InstallOptions{
+ Name: "test-uninstall",
+ Chart: "nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ }
+
+ // Install the release
+ _, err := hspm.Install(installOpts)
+ if err != nil {
+ t.Logf("Error installing release: %v", err)
+ t.Skip("Skipping uninstall test because install failed")
+ return
+ }
+
+ // Now uninstall it
+ uninstallOpts := options.UninstallOptions{
+ Name: "test-uninstall",
+ }
+ err = hspm.Uninstall(uninstallOpts)
+ is.NoError(err, "should successfully uninstall release")
+ })
+}
diff --git a/pkg/libhelm/sdk/values.go b/pkg/libhelm/sdk/values.go
new file mode 100644
index 000000000..37ad46775
--- /dev/null
+++ b/pkg/libhelm/sdk/values.go
@@ -0,0 +1,42 @@
+package sdk
+
+import (
+ "os"
+
+ "github.com/pkg/errors"
+ "github.com/rs/zerolog/log"
+)
+
+// GetHelmValuesFromFile reads the values file and parses it into a map[string]any
+// and returns the map.
+func (hspm *HelmSDKPackageManager) GetHelmValuesFromFile(valuesFile string) (map[string]any, error) {
+ var vals map[string]any
+ if valuesFile != "" {
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("values_file", valuesFile).
+ Msg("Reading values file")
+
+ valuesData, err := os.ReadFile(valuesFile)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("values_file", valuesFile).
+ Err(err).
+ Msg("Failed to read values file")
+ return nil, errors.Wrap(err, "failed to read values file")
+ }
+
+ vals, err = hspm.parseValues(valuesData)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("values_file", valuesFile).
+ Err(err).
+ Msg("Failed to parse values file")
+ return nil, errors.Wrap(err, "failed to parse values file")
+ }
+ }
+
+ return vals, nil
+}
diff --git a/pkg/libhelm/libhelmtest/integration.go b/pkg/libhelm/test/integration.go
similarity index 93%
rename from pkg/libhelm/libhelmtest/integration.go
rename to pkg/libhelm/test/integration.go
index f8febab65..0939f460e 100644
--- a/pkg/libhelm/libhelmtest/integration.go
+++ b/pkg/libhelm/test/integration.go
@@ -1,4 +1,4 @@
-package libhelmtest
+package test
import (
"os"
diff --git a/pkg/libhelm/binary/test/mock.go b/pkg/libhelm/test/mock.go
similarity index 95%
rename from pkg/libhelm/binary/test/mock.go
rename to pkg/libhelm/test/mock.go
index e019ee248..0b7712aab 100644
--- a/pkg/libhelm/binary/test/mock.go
+++ b/pkg/libhelm/test/mock.go
@@ -3,9 +3,9 @@ package test
import (
"strings"
- "github.com/portainer/portainer/pkg/libhelm"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/portainer/portainer/pkg/libhelm/types"
"github.com/pkg/errors"
"github.com/segmentio/encoding/json"
@@ -31,8 +31,8 @@ const (
// Do not use this package for concurrent tests.
type helmMockPackageManager struct{}
-// NewMockHelmBinaryPackageManager initializes a new HelmPackageManager service (a mock instance)
-func NewMockHelmBinaryPackageManager(binaryPath string) libhelm.HelmPackageManager {
+// NewMockHelmPackageManager initializes a new HelmPackageManager service (a mock instance)
+func NewMockHelmPackageManager() types.HelmPackageManager {
return &helmMockPackageManager{}
}
diff --git a/pkg/libhelm/helm.go b/pkg/libhelm/types/types.go
similarity index 72%
rename from pkg/libhelm/helm.go
rename to pkg/libhelm/types/types.go
index 3f718e64f..91b777270 100644
--- a/pkg/libhelm/helm.go
+++ b/pkg/libhelm/types/types.go
@@ -1,16 +1,26 @@
-package libhelm
+package types
import (
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
+ "helm.sh/helm/v3/pkg/cli"
+ "helm.sh/helm/v3/pkg/repo"
)
// HelmPackageManager represents a service that interfaces with Helm
type HelmPackageManager interface {
Show(showOpts options.ShowOptions) ([]byte, error)
SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error)
- Get(getOpts options.GetOptions) ([]byte, error)
List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
Install(installOpts options.InstallOptions) (*release.Release, error)
Uninstall(uninstallOpts options.UninstallOptions) error
}
+
+type Repository interface {
+ Charts() (repo.ChartVersions, error)
+}
+
+type HelmRepo struct {
+ Settings *cli.EnvSettings
+ Orig *repo.Entry
+}
diff --git a/pkg/libhelm/validate_repo.go b/pkg/libhelm/validate_repo.go
index 872dad165..1b4de5dc5 100644
--- a/pkg/libhelm/validate_repo.go
+++ b/pkg/libhelm/validate_repo.go
@@ -15,6 +15,10 @@ func ValidateHelmRepositoryURL(repoUrl string, client *http.Client) error {
return errors.New("URL is required")
}
+ if strings.HasPrefix(repoUrl, "oci://") {
+ return errors.New("OCI repositories are not supported yet")
+ }
+
url, err := url.ParseRequestURI(repoUrl)
if err != nil {
return fmt.Errorf("invalid helm repository URL '%s': %w", repoUrl, err)
diff --git a/pkg/libhelm/validate_repo_test.go b/pkg/libhelm/validate_repo_test.go
index dce1257d0..55d9d4736 100644
--- a/pkg/libhelm/validate_repo_test.go
+++ b/pkg/libhelm/validate_repo_test.go
@@ -3,12 +3,12 @@ package libhelm
import (
"testing"
- "github.com/portainer/portainer/pkg/libhelm/libhelmtest"
+ "github.com/portainer/portainer/pkg/libhelm/test"
"github.com/stretchr/testify/assert"
)
func Test_ValidateHelmRepositoryURL(t *testing.T) {
- libhelmtest.EnsureIntegrationTest(t)
+ test.EnsureIntegrationTest(t)
is := assert.New(t)
type testCase struct {
@@ -26,7 +26,7 @@ func Test_ValidateHelmRepositoryURL(t *testing.T) {
{"not helm repo", "http://google.com", true},
{"not valid repo with trailing slash", "http://google.com/", true},
{"not valid repo with trailing slashes", "http://google.com////", true},
- {"ingress helm repo", "https://kubernetes.github.io/ingress-nginx/", false},
+ {"bitnami helm repo", "https://charts.bitnami.com/bitnami/", false},
{"gitlap helm repo", "https://charts.gitlab.io/", false},
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
{"elastic helm repo", "https://helm.elastic.co/", false},
From 8b7aef883a96052a6f91b77712f1142527644dc3 Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Thu, 13 Mar 2025 14:13:18 +1300
Subject: [PATCH 041/212] fix: display unscheduled applications (#496)
Co-authored-by: JamesPlayer
---
api/http/handler/kubernetes/application.go | 9 +-
api/kubernetes/cli/applications.go | 410 +++++++++++-----
api/kubernetes/cli/applications_test.go | 461 ++++++++++++++++++
api/kubernetes/cli/configmap.go | 6 +-
api/kubernetes/cli/pod.go | 70 ++-
api/kubernetes/cli/role.go | 3 +-
api/kubernetes/cli/role_binding.go | 8 +-
api/kubernetes/cli/secret.go | 6 +-
api/kubernetes/cli/service.go | 4 +-
api/kubernetes/cli/service_account.go | 3 +-
api/kubernetes/cli/volumes.go | 8 +-
api/portainer.go | 2 +-
.../ApplicationsDatatable.tsx | 1 -
.../ApplicationsStacksDatatable.tsx | 1 -
.../applications/queries/query-keys.ts | 1 -
.../applications/queries/useApplications.ts | 1 -
app/react/kubernetes/applications/utils.ts | 2 +-
.../ItemView/NamespaceAppsDatatable.tsx | 1 -
18 files changed, 796 insertions(+), 201 deletions(-)
create mode 100644 api/kubernetes/cli/applications_test.go
diff --git a/api/http/handler/kubernetes/application.go b/api/http/handler/kubernetes/application.go
index 2da8d0fd4..cb67cd7af 100644
--- a/api/http/handler/kubernetes/application.go
+++ b/api/http/handler/kubernetes/application.go
@@ -69,7 +69,6 @@ func (handler *Handler) getApplicationsResources(w http.ResponseWriter, r *http.
// @param id path int true "Environment(Endpoint) identifier"
// @param namespace query string true "Namespace name"
// @param nodeName query string true "Node name"
-// @param withDependencies query boolean false "Include dependencies in the response"
// @success 200 {array} models.K8sApplication "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."
@@ -117,12 +116,6 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
return nil, httperror.BadRequest("Unable to parse the namespace query parameter", err)
}
- withDependencies, err := request.RetrieveBooleanQueryParameter(r, "withDependencies", true)
- if err != nil {
- log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the withDependencies query parameter")
- return nil, httperror.BadRequest("Unable to parse the withDependencies query parameter", err)
- }
-
nodeName, err := request.RetrieveQueryParameter(r, "nodeName", true)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Msg("Unable to parse the nodeName query parameter")
@@ -135,7 +128,7 @@ func (handler *Handler) getAllKubernetesApplications(r *http.Request) ([]models.
return nil, httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
}
- applications, err := cli.GetApplications(namespace, nodeName, withDependencies)
+ applications, err := cli.GetApplications(namespace, nodeName)
if err != nil {
if k8serrors.IsUnauthorized(err) {
log.Error().Err(err).Str("context", "getAllKubernetesApplications").Str("namespace", namespace).Str("nodeName", nodeName).Msg("Unable to get the list of applications")
diff --git a/api/kubernetes/cli/applications.go b/api/kubernetes/cli/applications.go
index e68137c69..c08329a7b 100644
--- a/api/kubernetes/cli/applications.go
+++ b/api/kubernetes/cli/applications.go
@@ -12,45 +12,58 @@ import (
labels "k8s.io/apimachinery/pkg/labels"
)
+// PortainerApplicationResources contains collections of various Kubernetes resources
+// associated with a Portainer application.
+type PortainerApplicationResources struct {
+ Pods []corev1.Pod
+ ReplicaSets []appsv1.ReplicaSet
+ Deployments []appsv1.Deployment
+ StatefulSets []appsv1.StatefulSet
+ DaemonSets []appsv1.DaemonSet
+ Services []corev1.Service
+ HorizontalPodAutoscalers []autoscalingv2.HorizontalPodAutoscaler
+}
+
// GetAllKubernetesApplications gets a list of kubernetes workloads (or applications) across all namespaces in the cluster
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function.
// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces.
-func (kcl *KubeClient) GetApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
+func (kcl *KubeClient) GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
if kcl.IsKubeAdmin {
- return kcl.fetchApplications(namespace, nodeName, withDependencies)
+ return kcl.fetchApplications(namespace, nodeName)
}
- return kcl.fetchApplicationsForNonAdmin(namespace, nodeName, withDependencies)
+ return kcl.fetchApplicationsForNonAdmin(namespace, nodeName)
}
// fetchApplications fetches the applications in the namespaces the user has access to.
// This function is called when the user is an admin.
-func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
+func (kcl *KubeClient) fetchApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
podListOptions := metav1.ListOptions{}
if nodeName != "" {
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
}
- if !withDependencies {
- // TODO: make sure not to fetch services in fetchAllApplicationsListResources from this call
- pods, replicaSets, deployments, statefulSets, daemonSets, _, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
- if err != nil {
- return nil, err
- }
- return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil, nil)
- }
-
- pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
+ portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
- return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
+ applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
+ if err != nil {
+ return nil, err
+ }
+
+ unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
+ if err != nil {
+ return nil, err
+ }
+
+ return append(applications, unhealthyApplications...), nil
}
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
// This function is called when the user is not an admin.
-func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string, withDependencies bool) ([]models.K8sApplication, error) {
+func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string) ([]models.K8sApplication, error) {
log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
@@ -62,28 +75,24 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
podListOptions.FieldSelector = "spec.nodeName=" + nodeName
}
- if !withDependencies {
- pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
- if err != nil {
- return nil, err
- }
-
- return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil, nil)
- }
-
- pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
+ portainerApplicationResources, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
- applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
+ applications, err := kcl.convertPodsToApplications(portainerApplicationResources)
+ if err != nil {
+ return nil, err
+ }
+
+ unhealthyApplications, err := fetchUnhealthyApplications(portainerApplicationResources)
if err != nil {
return nil, err
}
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
results := make([]models.K8sApplication, 0)
- for _, application := range applications {
+ for _, application := range append(applications, unhealthyApplications...) {
if _, ok := nonAdminNamespaceSet[application.ResourcePool]; ok {
results = append(results, application)
}
@@ -93,11 +102,11 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
}
// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference.
-func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) ([]models.K8sApplication, error) {
+func (kcl *KubeClient) convertPodsToApplications(portainerApplicationResources PortainerApplicationResources) ([]models.K8sApplication, error) {
applications := []models.K8sApplication{}
processedOwners := make(map[string]struct{})
- for _, pod := range pods {
+ for _, pod := range portainerApplicationResources.Pods {
if len(pod.OwnerReferences) > 0 {
ownerUID := string(pod.OwnerReferences[0].UID)
if _, exists := processedOwners[ownerUID]; exists {
@@ -106,7 +115,7 @@ func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets
processedOwners[ownerUID] = struct{}{}
}
- application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, hpas, true)
+ application, err := kcl.ConvertPodToApplication(pod, portainerApplicationResources, true)
if err != nil {
return nil, err
}
@@ -151,7 +160,9 @@ func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConf
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
- application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
+ application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
+ ReplicaSets: replicaSets,
+ }, false)
if err != nil {
return nil, err
}
@@ -168,7 +179,9 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
- application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
+ application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
+ ReplicaSets: replicaSets,
+ }, false)
if err != nil {
return nil, err
}
@@ -181,12 +194,12 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
}
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
-func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler, withResource bool) (*models.K8sApplication, error) {
+func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) {
if isReplicaSetOwner(pod) {
- updateOwnerReferenceToDeployment(&pod, replicaSets)
+ updateOwnerReferenceToDeployment(&pod, portainerApplicationResources.ReplicaSets)
}
- application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas)
+ application := createApplicationFromPod(&pod, portainerApplicationResources)
if application.ID == "" && application.Name == "" {
return nil, nil
}
@@ -203,9 +216,9 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []app
return &application, nil
}
-// createApplication creates a K8sApplication object from a pod
+// createApplicationFromPod creates a K8sApplication object from a pod
// it sets the application name, namespace, kind, image, stack id, stack name, and labels
-func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) models.K8sApplication {
+func createApplicationFromPod(pod *corev1.Pod, portainerApplicationResources PortainerApplicationResources) models.K8sApplication {
kind := "Pod"
name := pod.Name
@@ -221,120 +234,172 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu
switch kind {
case "Deployment":
- for _, deployment := range deployments {
+ for _, deployment := range portainerApplicationResources.Deployments {
if deployment.Name == name && deployment.Namespace == pod.Namespace {
- application.ApplicationType = "Deployment"
- application.Kind = "Deployment"
- application.ID = string(deployment.UID)
- application.ResourcePool = deployment.Namespace
- application.Name = name
- application.Image = deployment.Spec.Template.Spec.Containers[0].Image
- application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
- application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
- application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
- application.Labels = deployment.Labels
- application.MatchLabels = deployment.Spec.Selector.MatchLabels
- application.CreationDate = deployment.CreationTimestamp.Time
- application.TotalPodsCount = int(deployment.Status.Replicas)
- application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
- application.DeploymentType = "Replicated"
- application.Metadata = &models.Metadata{
- Labels: deployment.Labels,
- }
-
+ populateApplicationFromDeployment(&application, deployment)
break
}
}
-
case "StatefulSet":
- for _, statefulSet := range statefulSets {
+ for _, statefulSet := range portainerApplicationResources.StatefulSets {
if statefulSet.Name == name && statefulSet.Namespace == pod.Namespace {
- application.Kind = "StatefulSet"
- application.ApplicationType = "StatefulSet"
- application.ID = string(statefulSet.UID)
- application.ResourcePool = statefulSet.Namespace
- application.Name = name
- application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
- application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
- application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
- application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
- application.Labels = statefulSet.Labels
- application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
- application.CreationDate = statefulSet.CreationTimestamp.Time
- application.TotalPodsCount = int(statefulSet.Status.Replicas)
- application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
- application.DeploymentType = "Replicated"
- application.Metadata = &models.Metadata{
- Labels: statefulSet.Labels,
- }
-
+ populateApplicationFromStatefulSet(&application, statefulSet)
break
}
}
-
case "DaemonSet":
- for _, daemonSet := range daemonSets {
+ for _, daemonSet := range portainerApplicationResources.DaemonSets {
if daemonSet.Name == name && daemonSet.Namespace == pod.Namespace {
- application.Kind = "DaemonSet"
- application.ApplicationType = "DaemonSet"
- application.ID = string(daemonSet.UID)
- application.ResourcePool = daemonSet.Namespace
- application.Name = name
- application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
- application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
- application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
- application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
- application.Labels = daemonSet.Labels
- application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
- application.CreationDate = daemonSet.CreationTimestamp.Time
- application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
- application.RunningPodsCount = int(daemonSet.Status.NumberReady)
- application.DeploymentType = "Global"
- application.Metadata = &models.Metadata{
- Labels: daemonSet.Labels,
- }
-
+ populateApplicationFromDaemonSet(&application, daemonSet)
break
}
}
-
case "Pod":
- runningPodsCount := 1
- if pod.Status.Phase != corev1.PodRunning {
- runningPodsCount = 0
- }
-
- application.ApplicationType = "Pod"
- application.Kind = "Pod"
- application.ID = string(pod.UID)
- application.ResourcePool = pod.Namespace
- application.Name = pod.Name
- application.Image = pod.Spec.Containers[0].Image
- application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
- application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
- application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
- application.Labels = pod.Labels
- application.MatchLabels = pod.Labels
- application.CreationDate = pod.CreationTimestamp.Time
- application.TotalPodsCount = 1
- application.RunningPodsCount = runningPodsCount
- application.DeploymentType = string(pod.Status.Phase)
- application.Metadata = &models.Metadata{
- Labels: pod.Labels,
- }
+ populateApplicationFromPod(&application, *pod)
}
- if application.ID != "" && application.Name != "" && len(services) > 0 {
- updateApplicationWithService(&application, services)
+ if application.ID != "" && application.Name != "" && len(portainerApplicationResources.Services) > 0 {
+ updateApplicationWithService(&application, portainerApplicationResources.Services)
}
- if application.ID != "" && application.Name != "" && len(hpas) > 0 {
- updateApplicationWithHorizontalPodAutoscaler(&application, hpas)
+ if application.ID != "" && application.Name != "" && len(portainerApplicationResources.HorizontalPodAutoscalers) > 0 {
+ updateApplicationWithHorizontalPodAutoscaler(&application, portainerApplicationResources.HorizontalPodAutoscalers)
}
return application
}
+// createApplicationFromDeployment creates a K8sApplication from a Deployment
+func createApplicationFromDeployment(deployment appsv1.Deployment) models.K8sApplication {
+ var app models.K8sApplication
+ populateApplicationFromDeployment(&app, deployment)
+ return app
+}
+
+// createApplicationFromStatefulSet creates a K8sApplication from a StatefulSet
+func createApplicationFromStatefulSet(statefulSet appsv1.StatefulSet) models.K8sApplication {
+ var app models.K8sApplication
+ populateApplicationFromStatefulSet(&app, statefulSet)
+ return app
+}
+
+// createApplicationFromDaemonSet creates a K8sApplication from a DaemonSet
+func createApplicationFromDaemonSet(daemonSet appsv1.DaemonSet) models.K8sApplication {
+ var app models.K8sApplication
+ populateApplicationFromDaemonSet(&app, daemonSet)
+ return app
+}
+
+func populateApplicationFromDeployment(application *models.K8sApplication, deployment appsv1.Deployment) {
+ application.ApplicationType = "Deployment"
+ application.Kind = "Deployment"
+ application.ID = string(deployment.UID)
+ application.ResourcePool = deployment.Namespace
+ application.Name = deployment.Name
+ application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
+ application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
+ application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
+ application.Labels = deployment.Labels
+ application.MatchLabels = deployment.Spec.Selector.MatchLabels
+ application.CreationDate = deployment.CreationTimestamp.Time
+ application.TotalPodsCount = 0
+ if deployment.Spec.Replicas != nil {
+ application.TotalPodsCount = int(*deployment.Spec.Replicas)
+ }
+ application.RunningPodsCount = int(deployment.Status.ReadyReplicas)
+ application.DeploymentType = "Replicated"
+ application.Metadata = &models.Metadata{
+ Labels: deployment.Labels,
+ }
+
+ // If the deployment has containers, use the first container's image
+ if len(deployment.Spec.Template.Spec.Containers) > 0 {
+ application.Image = deployment.Spec.Template.Spec.Containers[0].Image
+ }
+}
+
+func populateApplicationFromStatefulSet(application *models.K8sApplication, statefulSet appsv1.StatefulSet) {
+ application.Kind = "StatefulSet"
+ application.ApplicationType = "StatefulSet"
+ application.ID = string(statefulSet.UID)
+ application.ResourcePool = statefulSet.Namespace
+ application.Name = statefulSet.Name
+ application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
+ application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
+ application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
+ application.Labels = statefulSet.Labels
+ application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
+ application.CreationDate = statefulSet.CreationTimestamp.Time
+ application.TotalPodsCount = 0
+ if statefulSet.Spec.Replicas != nil {
+ application.TotalPodsCount = int(*statefulSet.Spec.Replicas)
+ }
+ application.RunningPodsCount = int(statefulSet.Status.ReadyReplicas)
+ application.DeploymentType = "Replicated"
+ application.Metadata = &models.Metadata{
+ Labels: statefulSet.Labels,
+ }
+
+ // If the statefulSet has containers, use the first container's image
+ if len(statefulSet.Spec.Template.Spec.Containers) > 0 {
+ application.Image = statefulSet.Spec.Template.Spec.Containers[0].Image
+ }
+}
+
+func populateApplicationFromDaemonSet(application *models.K8sApplication, daemonSet appsv1.DaemonSet) {
+ application.Kind = "DaemonSet"
+ application.ApplicationType = "DaemonSet"
+ application.ID = string(daemonSet.UID)
+ application.ResourcePool = daemonSet.Namespace
+ application.Name = daemonSet.Name
+ application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
+ application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
+ application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
+ application.Labels = daemonSet.Labels
+ application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
+ application.CreationDate = daemonSet.CreationTimestamp.Time
+ application.TotalPodsCount = int(daemonSet.Status.DesiredNumberScheduled)
+ application.RunningPodsCount = int(daemonSet.Status.NumberReady)
+ application.DeploymentType = "Global"
+ application.Metadata = &models.Metadata{
+ Labels: daemonSet.Labels,
+ }
+
+ if len(daemonSet.Spec.Template.Spec.Containers) > 0 {
+ application.Image = daemonSet.Spec.Template.Spec.Containers[0].Image
+ }
+}
+
+func populateApplicationFromPod(application *models.K8sApplication, pod corev1.Pod) {
+ runningPodsCount := 1
+ if pod.Status.Phase != corev1.PodRunning {
+ runningPodsCount = 0
+ }
+
+ application.ApplicationType = "Pod"
+ application.Kind = "Pod"
+ application.ID = string(pod.UID)
+ application.ResourcePool = pod.Namespace
+ application.Name = pod.Name
+ application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
+ application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
+ application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
+ application.Labels = pod.Labels
+ application.MatchLabels = pod.Labels
+ application.CreationDate = pod.CreationTimestamp.Time
+ application.TotalPodsCount = 1
+ application.RunningPodsCount = runningPodsCount
+ application.DeploymentType = string(pod.Status.Phase)
+ application.Metadata = &models.Metadata{
+ Labels: pod.Labels,
+ }
+
+ // If the pod has containers, use the first container's image
+ if len(pod.Spec.Containers) > 0 {
+ application.Image = pod.Spec.Containers[0].Image
+ }
+}
+
// updateApplicationWithService updates the application with the services that match the application's selector match labels
// and are in the same namespace as the application
func updateApplicationWithService(application *models.K8sApplication, services []corev1.Service) {
@@ -410,7 +475,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
- application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
+ application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
+ ReplicaSets: replicaSets,
+ }, false)
if err != nil {
return nil, err
}
@@ -436,7 +503,9 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
- application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
+ application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
+ ReplicaSets: replicaSets,
+ }, false)
if err != nil {
return nil, err
}
@@ -454,3 +523,84 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
return configurationOwners, nil
}
+
+// fetchUnhealthyApplications fetches applications that failed to schedule any pods
+// due to issues like missing resource limits or other scheduling constraints
+func fetchUnhealthyApplications(resources PortainerApplicationResources) ([]models.K8sApplication, error) {
+ var unhealthyApplications []models.K8sApplication
+
+ // Process Deployments
+ for _, deployment := range resources.Deployments {
+ if hasNoScheduledPods(deployment) {
+ app := createApplicationFromDeployment(deployment)
+ addRelatedResourcesToApplication(&app, resources)
+ unhealthyApplications = append(unhealthyApplications, app)
+ }
+ }
+
+ // Process StatefulSets
+ for _, statefulSet := range resources.StatefulSets {
+ if hasNoScheduledPods(statefulSet) {
+ app := createApplicationFromStatefulSet(statefulSet)
+ addRelatedResourcesToApplication(&app, resources)
+ unhealthyApplications = append(unhealthyApplications, app)
+ }
+ }
+
+ // Process DaemonSets
+ for _, daemonSet := range resources.DaemonSets {
+ if hasNoScheduledPods(daemonSet) {
+ app := createApplicationFromDaemonSet(daemonSet)
+ addRelatedResourcesToApplication(&app, resources)
+ unhealthyApplications = append(unhealthyApplications, app)
+ }
+ }
+
+ return unhealthyApplications, nil
+}
+
+// addRelatedResourcesToApplication adds Services and HPA information to the application
+func addRelatedResourcesToApplication(app *models.K8sApplication, resources PortainerApplicationResources) {
+ if app.ID == "" || app.Name == "" {
+ return
+ }
+
+ if len(resources.Services) > 0 {
+ updateApplicationWithService(app, resources.Services)
+ }
+
+ if len(resources.HorizontalPodAutoscalers) > 0 {
+ updateApplicationWithHorizontalPodAutoscaler(app, resources.HorizontalPodAutoscalers)
+ }
+}
+
+// hasNoScheduledPods checks if a workload has completely failed to schedule any pods
+// it checks for no replicas desired, i.e. nothing to schedule and see if any pods are running
+// if any pods exist at all (even if not ready), it returns false
+func hasNoScheduledPods(obj interface{}) bool {
+ switch resource := obj.(type) {
+ case appsv1.Deployment:
+ if resource.Status.Replicas > 0 {
+ return false
+ }
+
+ return resource.Status.ReadyReplicas == 0 && resource.Status.AvailableReplicas == 0
+
+ case appsv1.StatefulSet:
+ if resource.Status.Replicas > 0 {
+ return false
+ }
+
+ return resource.Status.ReadyReplicas == 0 && resource.Status.CurrentReplicas == 0
+
+ case appsv1.DaemonSet:
+ if resource.Status.CurrentNumberScheduled > 0 || resource.Status.NumberMisscheduled > 0 {
+ return false
+ }
+
+ return resource.Status.NumberReady == 0 && resource.Status.DesiredNumberScheduled > 0
+
+ default:
+ return false
+ }
+}
diff --git a/api/kubernetes/cli/applications_test.go b/api/kubernetes/cli/applications_test.go
new file mode 100644
index 000000000..81a5cfb71
--- /dev/null
+++ b/api/kubernetes/cli/applications_test.go
@@ -0,0 +1,461 @@
+package cli
+
+import (
+ "context"
+ "testing"
+
+ models "github.com/portainer/portainer/api/http/models/kubernetes"
+ "github.com/stretchr/testify/assert"
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/kubernetes/fake"
+)
+
+// Helper functions to create test resources
+func createTestDeployment(name, namespace string, replicas int32) *appsv1.Deployment {
+ return &appsv1.Deployment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("deploy-" + name),
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: appsv1.DeploymentSpec{
+ Replicas: &replicas,
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "app": name,
+ },
+ },
+ Template: corev1.PodTemplateSpec{
+ ObjectMeta: metav1.ObjectMeta{
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {
+ Name: name,
+ Image: "nginx:latest",
+ Resources: corev1.ResourceRequirements{
+ Limits: corev1.ResourceList{},
+ Requests: corev1.ResourceList{},
+ },
+ },
+ },
+ },
+ },
+ },
+ Status: appsv1.DeploymentStatus{
+ Replicas: replicas,
+ ReadyReplicas: replicas,
+ },
+ }
+}
+
+func createTestReplicaSet(name, namespace, deploymentName string) *appsv1.ReplicaSet {
+ return &appsv1.ReplicaSet{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("rs-" + name),
+ OwnerReferences: []metav1.OwnerReference{
+ {
+ Kind: "Deployment",
+ Name: deploymentName,
+ UID: types.UID("deploy-" + deploymentName),
+ },
+ },
+ },
+ Spec: appsv1.ReplicaSetSpec{
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "app": deploymentName,
+ },
+ },
+ },
+ }
+}
+
+func createTestStatefulSet(name, namespace string, replicas int32) *appsv1.StatefulSet {
+ return &appsv1.StatefulSet{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("sts-" + name),
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: appsv1.StatefulSetSpec{
+ Replicas: &replicas,
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "app": name,
+ },
+ },
+ Template: corev1.PodTemplateSpec{
+ ObjectMeta: metav1.ObjectMeta{
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {
+ Name: name,
+ Image: "redis:latest",
+ Resources: corev1.ResourceRequirements{
+ Limits: corev1.ResourceList{},
+ Requests: corev1.ResourceList{},
+ },
+ },
+ },
+ },
+ },
+ },
+ Status: appsv1.StatefulSetStatus{
+ Replicas: replicas,
+ ReadyReplicas: replicas,
+ },
+ }
+}
+
+func createTestDaemonSet(name, namespace string) *appsv1.DaemonSet {
+ return &appsv1.DaemonSet{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("ds-" + name),
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: appsv1.DaemonSetSpec{
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{
+ "app": name,
+ },
+ },
+ Template: corev1.PodTemplateSpec{
+ ObjectMeta: metav1.ObjectMeta{
+ Labels: map[string]string{
+ "app": name,
+ },
+ },
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {
+ Name: name,
+ Image: "fluentd:latest",
+ Resources: corev1.ResourceRequirements{
+ Limits: corev1.ResourceList{},
+ Requests: corev1.ResourceList{},
+ },
+ },
+ },
+ },
+ },
+ },
+ Status: appsv1.DaemonSetStatus{
+ DesiredNumberScheduled: 2,
+ NumberReady: 2,
+ },
+ }
+}
+
+func createTestPod(name, namespace, ownerKind, ownerName string, isRunning bool) *corev1.Pod {
+ phase := corev1.PodPending
+ if isRunning {
+ phase = corev1.PodRunning
+ }
+
+ var ownerReferences []metav1.OwnerReference
+ if ownerKind != "" && ownerName != "" {
+ ownerReferences = []metav1.OwnerReference{
+ {
+ Kind: ownerKind,
+ Name: ownerName,
+ UID: types.UID(ownerKind + "-" + ownerName),
+ },
+ }
+ }
+
+ return &corev1.Pod{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("pod-" + name),
+ OwnerReferences: ownerReferences,
+ Labels: map[string]string{
+ "app": ownerName,
+ },
+ },
+ Spec: corev1.PodSpec{
+ Containers: []corev1.Container{
+ {
+ Name: "container-" + name,
+ Image: "busybox:latest",
+ Resources: corev1.ResourceRequirements{
+ Limits: corev1.ResourceList{},
+ Requests: corev1.ResourceList{},
+ },
+ },
+ },
+ },
+ Status: corev1.PodStatus{
+ Phase: phase,
+ },
+ }
+}
+
+func createTestService(name, namespace string, selector map[string]string) *corev1.Service {
+ return &corev1.Service{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ UID: types.UID("svc-" + name),
+ },
+ Spec: corev1.ServiceSpec{
+ Selector: selector,
+ Type: corev1.ServiceTypeClusterIP,
+ },
+ }
+}
+
+func TestGetApplications(t *testing.T) {
+ t.Run("Admin user - Mix of deployments, statefulsets and daemonsets with and without pods", func(t *testing.T) {
+ // Create a fake K8s client
+ fakeClient := fake.NewSimpleClientset()
+
+ // Setup the test namespace
+ namespace := "test-namespace"
+ defaultNamespace := "default"
+
+ // Create resources in the test namespace
+ // 1. Deployment with pods
+ deployWithPods := createTestDeployment("deploy-with-pods", namespace, 2)
+ _, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployWithPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ replicaSet := createTestReplicaSet("rs-deploy-with-pods", namespace, "deploy-with-pods")
+ _, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), replicaSet, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod1 := createTestPod("pod1-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod2 := createTestPod("pod2-deploy", namespace, "ReplicaSet", "rs-deploy-with-pods", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 2. Deployment without pods (scaled to 0)
+ deployNoPods := createTestDeployment("deploy-no-pods", namespace, 0)
+ _, err = fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deployNoPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 3. StatefulSet with pods
+ stsWithPods := createTestStatefulSet("sts-with-pods", namespace, 1)
+ _, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsWithPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod3 := createTestPod("pod1-sts", namespace, "StatefulSet", "sts-with-pods", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod3, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 4. StatefulSet without pods
+ stsNoPods := createTestStatefulSet("sts-no-pods", namespace, 0)
+ _, err = fakeClient.AppsV1().StatefulSets(namespace).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 5. DaemonSet with pods
+ dsWithPods := createTestDaemonSet("ds-with-pods", namespace)
+ _, err = fakeClient.AppsV1().DaemonSets(namespace).Create(context.TODO(), dsWithPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod4 := createTestPod("pod1-ds", namespace, "DaemonSet", "ds-with-pods", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod4, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod5 := createTestPod("pod2-ds", namespace, "DaemonSet", "ds-with-pods", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod5, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 6. Naked Pod (no owner reference)
+ nakedPod := createTestPod("naked-pod", namespace, "", "", true)
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), nakedPod, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 7. Resources in another namespace
+ deployOtherNs := createTestDeployment("deploy-other-ns", defaultNamespace, 1)
+ _, err = fakeClient.AppsV1().Deployments(defaultNamespace).Create(context.TODO(), deployOtherNs, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ podOtherNs := createTestPod("pod-other-ns", defaultNamespace, "Deployment", "deploy-other-ns", true)
+ _, err = fakeClient.CoreV1().Pods(defaultNamespace).Create(context.TODO(), podOtherNs, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // 8. Add a service (dependency)
+ service := createTestService("svc-deploy", namespace, map[string]string{"app": "deploy-with-pods"})
+ _, err = fakeClient.CoreV1().Services(namespace).Create(context.TODO(), service, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create the KubeClient with admin privileges
+ kubeClient := &KubeClient{
+ cli: fakeClient,
+ instanceID: "test-instance",
+ IsKubeAdmin: true,
+ }
+
+ // Test cases
+
+ // 1. All resources, no filtering
+ t.Run("All resources with dependencies", func(t *testing.T) {
+ apps, err := kubeClient.GetApplications("", "")
+ assert.NoError(t, err)
+
+ // We expect 7 resources: 2 deployments + 2 statefulsets + 1 daemonset + 1 naked pod + 1 deployment in other namespace
+ // Note: Each controller with pods should count once, not per pod
+ assert.Equal(t, 7, len(apps))
+
+ // Verify one of the deployments has services attached
+ appsWithServices := []models.K8sApplication{}
+ for _, app := range apps {
+ if len(app.Services) > 0 {
+ appsWithServices = append(appsWithServices, app)
+ }
+ }
+ assert.Equal(t, 1, len(appsWithServices))
+ assert.Equal(t, "deploy-with-pods", appsWithServices[0].Name)
+ })
+
+ // 2. Filter by namespace
+ t.Run("Filter by namespace", func(t *testing.T) {
+ apps, err := kubeClient.GetApplications(namespace, "")
+ assert.NoError(t, err)
+
+ // We expect 6 resources in the test namespace
+ assert.Equal(t, 6, len(apps))
+
+ // Verify resources from other namespaces are not included
+ for _, app := range apps {
+ assert.Equal(t, namespace, app.ResourcePool)
+ }
+ })
+ })
+
+ t.Run("Non-admin user - Resources filtered by accessible namespaces", func(t *testing.T) {
+ // Create a fake K8s client
+ fakeClient := fake.NewSimpleClientset()
+
+ // Setup the test namespaces
+ namespace1 := "allowed-ns"
+ namespace2 := "restricted-ns"
+
+ // Create resources in the allowed namespace
+ sts1 := createTestStatefulSet("sts-allowed", namespace1, 1)
+ _, err := fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), sts1, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod1 := createTestPod("pod-allowed", namespace1, "StatefulSet", "sts-allowed", true)
+ _, err = fakeClient.CoreV1().Pods(namespace1).Create(context.TODO(), pod1, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Add a StatefulSet without pods in the allowed namespace
+ stsNoPods := createTestStatefulSet("sts-no-pods-allowed", namespace1, 0)
+ _, err = fakeClient.AppsV1().StatefulSets(namespace1).Create(context.TODO(), stsNoPods, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create resources in the restricted namespace
+ sts2 := createTestStatefulSet("sts-restricted", namespace2, 1)
+ _, err = fakeClient.AppsV1().StatefulSets(namespace2).Create(context.TODO(), sts2, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod2 := createTestPod("pod-restricted", namespace2, "StatefulSet", "sts-restricted", true)
+ _, err = fakeClient.CoreV1().Pods(namespace2).Create(context.TODO(), pod2, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create the KubeClient with non-admin privileges (only allowed namespace1)
+ kubeClient := &KubeClient{
+ cli: fakeClient,
+ instanceID: "test-instance",
+ IsKubeAdmin: false,
+ NonAdminNamespaces: []string{namespace1},
+ }
+
+ // Test that only resources from allowed namespace are returned
+ apps, err := kubeClient.GetApplications("", "")
+ assert.NoError(t, err)
+
+ // We expect 2 resources from the allowed namespace (1 sts with pod + 1 sts without pod)
+ assert.Equal(t, 2, len(apps))
+
+ // Verify resources are from the allowed namespace
+ for _, app := range apps {
+ assert.Equal(t, namespace1, app.ResourcePool)
+ assert.Equal(t, "StatefulSet", app.Kind)
+ }
+
+ // Verify names of returned resources
+ stsNames := make(map[string]bool)
+ for _, app := range apps {
+ stsNames[app.Name] = true
+ }
+
+ assert.True(t, stsNames["sts-allowed"], "Expected StatefulSet 'sts-allowed' was not found")
+ assert.True(t, stsNames["sts-no-pods-allowed"], "Expected StatefulSet 'sts-no-pods-allowed' was not found")
+ })
+
+ t.Run("Filter by node name", func(t *testing.T) {
+ // Create a fake K8s client
+ fakeClient := fake.NewSimpleClientset()
+
+ // Setup test namespace
+ namespace := "node-filter-ns"
+ nodeName := "worker-node-1"
+
+ // Create a deployment with pods on specific node
+ deploy := createTestDeployment("node-deploy", namespace, 2)
+ _, err := fakeClient.AppsV1().Deployments(namespace).Create(context.TODO(), deploy, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create ReplicaSet for the deployment
+ rs := createTestReplicaSet("rs-node-deploy", namespace, "node-deploy")
+ _, err = fakeClient.AppsV1().ReplicaSets(namespace).Create(context.TODO(), rs, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create 2 pods, one on the specified node, one on a different node
+ pod1 := createTestPod("pod-on-node", namespace, "ReplicaSet", "rs-node-deploy", true)
+ pod1.Spec.NodeName = nodeName
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod1, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ pod2 := createTestPod("pod-other-node", namespace, "ReplicaSet", "rs-node-deploy", true)
+ pod2.Spec.NodeName = "worker-node-2"
+ _, err = fakeClient.CoreV1().Pods(namespace).Create(context.TODO(), pod2, metav1.CreateOptions{})
+ assert.NoError(t, err)
+
+ // Create the KubeClient
+ kubeClient := &KubeClient{
+ cli: fakeClient,
+ instanceID: "test-instance",
+ IsKubeAdmin: true,
+ }
+
+ // Test filtering by node name
+ apps, err := kubeClient.GetApplications(namespace, nodeName)
+ assert.NoError(t, err)
+
+ // We expect to find only the pod on the specified node
+ assert.Equal(t, 1, len(apps))
+ if len(apps) > 0 {
+ assert.Equal(t, "node-deploy", apps[0].Name)
+ }
+ })
+}
diff --git a/api/kubernetes/cli/configmap.go b/api/kubernetes/cli/configmap.go
index 57f36cf74..eb0eec935 100644
--- a/api/kubernetes/cli/configmap.go
+++ b/api/kubernetes/cli/configmap.go
@@ -24,7 +24,7 @@ func (kcl *KubeClient) GetConfigMaps(namespace string) ([]models.K8sConfigMap, e
// fetchConfigMapsForNonAdmin fetches the configMaps in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) fetchConfigMapsForNonAdmin(namespace string) ([]models.K8sConfigMap, error) {
- log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
+ log.Debug().Msgf("Fetching configMaps for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
@@ -102,7 +102,7 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
- pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
+ portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
@@ -110,7 +110,7 @@ func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8s
for index, configMap := range configMaps {
updatedConfigMap := configMap
- applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods, replicaSets)
+ applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to get applications from config map. Error: %w", err)
}
diff --git a/api/kubernetes/cli/pod.go b/api/kubernetes/cli/pod.go
index 8d22a20db..bb3e46077 100644
--- a/api/kubernetes/cli/pod.go
+++ b/api/kubernetes/cli/pod.go
@@ -11,7 +11,6 @@ import (
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
- autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -110,7 +109,7 @@ func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountNam
},
}
- shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(ctx, podSpec, metav1.CreateOptions{})
+ shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(context.TODO(), podSpec, metav1.CreateOptions{})
if err != nil {
return nil, errors.Wrap(err, "error creating shell pod")
}
@@ -158,7 +157,7 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
case <-ctx.Done():
return ctx.Err()
default:
- pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(ctx, pod.Name, metav1.GetOptions{})
+ pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(context.TODO(), pod.Name, metav1.GetOptions{})
if err != nil {
return err
}
@@ -172,70 +171,67 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
}
}
-// fetchAllPodsAndReplicaSets fetches all pods and replica sets across the cluster, i.e. all namespaces
-func (kcl *KubeClient) fetchAllPodsAndReplicaSets(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
- return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, false, false)
-}
-
// fetchAllApplicationsListResources fetches all pods, replica sets, stateful sets, and daemon sets across the cluster, i.e. all namespaces
// this is required for the applications list view
-func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
+func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) (PortainerApplicationResources, error) {
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, true, true)
}
// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references
-func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
+func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) (PortainerApplicationResources, error) {
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions)
if err != nil {
if k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, nil
+ return PortainerApplicationResources{}, nil
}
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err)
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list pods across the cluster: %w", err)
}
- // if replicaSet owner reference exists, fetch the replica sets
- // this also means that the deployments will be fetched because deployments own replica sets
- replicaSets := &appsv1.ReplicaSetList{}
- deployments := &appsv1.DeploymentList{}
- if containsReplicaSetOwnerReference(pods) {
- replicaSets, err = kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
- if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
- }
-
- deployments, err = kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
- if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list deployments across the cluster: %w", err)
- }
+ portainerApplicationResources := PortainerApplicationResources{
+ Pods: pods.Items,
}
- statefulSets := &appsv1.StatefulSetList{}
- if includeStatefulSets && containsStatefulSetOwnerReference(pods) {
- statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
+ replicaSets, err := kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
+ if err != nil && !k8serrors.IsNotFound(err) {
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
+ }
+ portainerApplicationResources.ReplicaSets = replicaSets.Items
+
+ deployments, err := kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
+ if err != nil && !k8serrors.IsNotFound(err) {
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list deployments across the cluster: %w", err)
+ }
+ portainerApplicationResources.Deployments = deployments.Items
+
+ if includeStatefulSets {
+ statefulSets, err := kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
}
+ portainerApplicationResources.StatefulSets = statefulSets.Items
}
- daemonSets := &appsv1.DaemonSetList{}
- if includeDaemonSets && containsDaemonSetOwnerReference(pods) {
- daemonSets, err = kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
+ if includeDaemonSets {
+ daemonSets, err := kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
}
+ portainerApplicationResources.DaemonSets = daemonSets.Items
}
services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err)
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list services across the cluster: %w", err)
}
+ portainerApplicationResources.Services = services.Items
hpas, err := kcl.cli.AutoscalingV2().HorizontalPodAutoscalers(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
- return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
+ return PortainerApplicationResources{}, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
}
+ portainerApplicationResources.HorizontalPodAutoscalers = hpas.Items
- return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, hpas.Items, nil
+ return portainerApplicationResources, nil
}
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
diff --git a/api/kubernetes/cli/role.go b/api/kubernetes/cli/role.go
index 9a3c6635f..c45e2c299 100644
--- a/api/kubernetes/cli/role.go
+++ b/api/kubernetes/cli/role.go
@@ -10,7 +10,6 @@ import (
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint.
@@ -137,7 +136,7 @@ func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
for _, name := range reqs[namespace] {
client := kcl.cli.RbacV1().Roles(namespace)
- role, err := client.Get(context.Background(), name, v1.GetOptions{})
+ role, err := client.Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
diff --git a/api/kubernetes/cli/role_binding.go b/api/kubernetes/cli/role_binding.go
index e8e90cbb0..7775748e2 100644
--- a/api/kubernetes/cli/role_binding.go
+++ b/api/kubernetes/cli/role_binding.go
@@ -7,11 +7,9 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
"github.com/rs/zerolog/log"
- corev1 "k8s.io/api/rbac/v1"
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint.
@@ -98,7 +96,7 @@ func (kcl *KubeClient) isSystemRoleBinding(rb *rbacv1.RoleBinding) bool {
return false
}
-func (kcl *KubeClient) getRole(namespace, name string) (*corev1.Role, error) {
+func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) {
client := kcl.cli.RbacV1().Roles(namespace)
return client.Get(context.Background(), name, metav1.GetOptions{})
}
@@ -111,7 +109,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
for _, name := range reqs[namespace] {
client := kcl.cli.RbacV1().RoleBindings(namespace)
- roleBinding, err := client.Get(context.Background(), name, v1.GetOptions{})
+ roleBinding, err := client.Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
@@ -125,7 +123,7 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role binding, not allowed")
}
- if err := client.Delete(context.Background(), name, v1.DeleteOptions{}); err != nil {
+ if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
errors = append(errors, err)
}
}
diff --git a/api/kubernetes/cli/secret.go b/api/kubernetes/cli/secret.go
index 8e38c9857..5b7bf4fef 100644
--- a/api/kubernetes/cli/secret.go
+++ b/api/kubernetes/cli/secret.go
@@ -31,7 +31,7 @@ func (kcl *KubeClient) GetSecrets(namespace string) ([]models.K8sSecret, error)
// getSecretsForNonAdmin fetches the secrets in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) getSecretsForNonAdmin(namespace string) ([]models.K8sSecret, error) {
- log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
+ log.Debug().Msgf("Fetching secrets for non-admin user: %v", kcl.NonAdminNamespaces)
if len(kcl.NonAdminNamespaces) == 0 {
return nil, nil
@@ -118,7 +118,7 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
updatedSecrets := make([]models.K8sSecret, len(secrets))
- pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
+ portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
@@ -126,7 +126,7 @@ func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret
for index, secret := range secrets {
updatedSecret := secret
- applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods, replicaSets)
+ applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to get applications from secret. Error: %w", err)
}
diff --git a/api/kubernetes/cli/service.go b/api/kubernetes/cli/service.go
index a70c5ca3e..3f0543735 100644
--- a/api/kubernetes/cli/service.go
+++ b/api/kubernetes/cli/service.go
@@ -174,7 +174,7 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf
func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) {
if containsServiceWithSelector(services) {
updatedServices := make([]models.K8sServiceInfo, len(services))
- pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
+ portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
@@ -182,7 +182,7 @@ func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServ
for index, service := range services {
updatedService := service
- application, err := kcl.GetApplicationFromServiceSelector(pods, service, replicaSets)
+ application, err := kcl.GetApplicationFromServiceSelector(portainerApplicationResources.Pods, service, portainerApplicationResources.ReplicaSets)
if err != nil {
return services, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to get application from service. Error: %w", err)
}
diff --git a/api/kubernetes/cli/service_account.go b/api/kubernetes/cli/service_account.go
index 831080efc..af674d794 100644
--- a/api/kubernetes/cli/service_account.go
+++ b/api/kubernetes/cli/service_account.go
@@ -5,7 +5,6 @@ import (
"fmt"
portainer "github.com/portainer/portainer/api"
- "github.com/portainer/portainer/api/http/models/kubernetes"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
corev1 "k8s.io/api/core/v1"
@@ -92,7 +91,7 @@ func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool {
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
// in its given namespace.
-func (kcl *KubeClient) DeleteServiceAccounts(reqs kubernetes.K8sServiceAccountDeleteRequests) error {
+func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error {
var errors []error
for namespace := range reqs {
for _, serviceName := range reqs[namespace] {
diff --git a/api/kubernetes/cli/volumes.go b/api/kubernetes/cli/volumes.go
index 52ee02ab8..d8a3cbf56 100644
--- a/api/kubernetes/cli/volumes.go
+++ b/api/kubernetes/cli/volumes.go
@@ -7,7 +7,6 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
- autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
@@ -265,7 +264,12 @@ func (kcl *KubeClient) updateVolumesWithOwningApplications(volumes *[]models.K8s
if pod.Spec.Volumes != nil {
for _, podVolume := range pod.Spec.Volumes {
if podVolume.VolumeSource.PersistentVolumeClaim != nil && podVolume.VolumeSource.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace {
- application, err := kcl.ConvertPodToApplication(pod, replicaSetItems, deploymentItems, statefulSetItems, daemonSetItems, []corev1.Service{}, []autoscalingv2.HorizontalPodAutoscaler{}, false)
+ application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
+ ReplicaSets: replicaSetItems,
+ Deployments: deploymentItems,
+ StatefulSets: statefulSetItems,
+ DaemonSets: daemonSetItems,
+ }, false)
if err != nil {
log.Error().Err(err).Msg("Failed to convert pod to application")
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to convert pod to application. Error: %w", err)
diff --git a/api/portainer.go b/api/portainer.go
index 9e1451633..5b7d177da 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -1544,7 +1544,7 @@ type (
GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
GetSecrets(namespace string) ([]models.K8sSecret, error)
GetIngressControllers() (models.K8sIngressControllers, error)
- GetApplications(namespace, nodename string, withDependencies bool) ([]models.K8sApplication, error)
+ GetApplications(namespace, nodename string) ([]models.K8sApplication, error)
GetMetrics() (models.K8sMetrics, error)
GetStorage() ([]KubernetesStorageClassConfig, error)
CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx
index a12cbd20e..4e6712939 100644
--- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx
+++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx
@@ -57,7 +57,6 @@ export function ApplicationsDatatable({
const applicationsQuery = useApplications(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
namespace: tableState.namespace,
- withDependencies: true,
});
const ingressesQuery = useIngresses(environmentId);
const ingresses = ingressesQuery.data ?? [];
diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx
index f95dbe19b..f0e1bf622 100644
--- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx
+++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/ApplicationsStacksDatatable.tsx
@@ -38,7 +38,6 @@ export function ApplicationsStacksDatatable({
const applicationsQuery = useApplications(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
namespace: tableState.namespace,
- withDependencies: true,
});
const ingressesQuery = useIngresses(environmentId);
const ingresses = ingressesQuery.data ?? [];
diff --git a/app/react/kubernetes/applications/queries/query-keys.ts b/app/react/kubernetes/applications/queries/query-keys.ts
index 87c042dc4..f787b92bf 100644
--- a/app/react/kubernetes/applications/queries/query-keys.ts
+++ b/app/react/kubernetes/applications/queries/query-keys.ts
@@ -3,7 +3,6 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
export type GetAppsParams = {
namespace?: string;
nodeName?: string;
- withDependencies?: boolean;
};
export const queryKeys = {
diff --git a/app/react/kubernetes/applications/queries/useApplications.ts b/app/react/kubernetes/applications/queries/useApplications.ts
index 9d09ffad0..6419dd853 100644
--- a/app/react/kubernetes/applications/queries/useApplications.ts
+++ b/app/react/kubernetes/applications/queries/useApplications.ts
@@ -11,7 +11,6 @@ import { queryKeys } from './query-keys';
type GetAppsParams = {
namespace?: string;
nodeName?: string;
- withDependencies?: boolean;
};
type GetAppsQueryOptions = {
diff --git a/app/react/kubernetes/applications/utils.ts b/app/react/kubernetes/applications/utils.ts
index d705e75c1..a919edd76 100644
--- a/app/react/kubernetes/applications/utils.ts
+++ b/app/react/kubernetes/applications/utils.ts
@@ -33,7 +33,7 @@ export function isExternalApplication(application: Application) {
function getDeploymentRunningPods(deployment: Deployment): number {
const availableReplicas = deployment.status?.availableReplicas ?? 0;
- const totalReplicas = deployment.status?.replicas ?? 0;
+ const totalReplicas = deployment.spec?.replicas ?? 0;
const unavailableReplicas = deployment.status?.unavailableReplicas ?? 0;
return availableReplicas || totalReplicas - unavailableReplicas;
}
diff --git a/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx b/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx
index 1ab17c5d7..e66e9f875 100644
--- a/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx
+++ b/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx
@@ -30,7 +30,6 @@ export function NamespaceAppsDatatable({ namespace }: { namespace: string }) {
const applicationsQuery = useApplications(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
namespace,
- withDependencies: true,
});
const applications = applicationsQuery.data ?? [];
From 417891675d80efe1c30e7b2aaeb5a3d3ccf903ca Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Thu, 13 Mar 2025 16:43:56 +1300
Subject: [PATCH 042/212] fix: ensure no non-admin users have access to system
namespaces (#499)
---
api/kubernetes/cli/namespace.go | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go
index 0ebb6189a..11307d651 100644
--- a/api/kubernetes/cli/namespace.go
+++ b/api/kubernetes/cli/namespace.go
@@ -265,9 +265,12 @@ func isSystemNamespace(namespace *corev1.Namespace) bool {
return systemLabelValue == "true"
}
- systemNamespaces := defaultSystemNamespaces()
+ return isSystemDefaultNamespace(namespace.Name)
+}
- _, isSystem := systemNamespaces[namespace.Name]
+func isSystemDefaultNamespace(namespace string) bool {
+ systemNamespaces := defaultSystemNamespaces()
+ _, isSystem := systemNamespaces[namespace]
return isSystem
}
@@ -390,7 +393,9 @@ func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portainer.K8s
func (kcl *KubeClient) buildNonAdminNamespacesMap() map[string]struct{} {
nonAdminNamespaceSet := make(map[string]struct{}, len(kcl.NonAdminNamespaces))
for _, namespace := range kcl.NonAdminNamespaces {
- nonAdminNamespaceSet[namespace] = struct{}{}
+ if !isSystemDefaultNamespace(namespace) {
+ nonAdminNamespaceSet[namespace] = struct{}{}
+ }
}
return nonAdminNamespaceSet
From 58317edb6dad965bcd5cc3e4952a73f29d310757 Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Fri, 14 Mar 2025 07:57:06 +1300
Subject: [PATCH 043/212] fix(namespaces): only show namespaces with access
[r8s-251] (#501)
---
app/kubernetes/services/resourcePoolService.js | 13 ++++++++++---
app/kubernetes/views/deploy/deployController.js | 6 +++---
2 files changed, 13 insertions(+), 6 deletions(-)
diff --git a/app/kubernetes/services/resourcePoolService.js b/app/kubernetes/services/resourcePoolService.js
index 639c44db1..d829afa0b 100644
--- a/app/kubernetes/services/resourcePoolService.js
+++ b/app/kubernetes/services/resourcePoolService.js
@@ -3,6 +3,7 @@ import _ from 'lodash-es';
import angular from 'angular';
import KubernetesResourcePoolConverter from 'Kubernetes/converters/resourcePool';
import KubernetesResourceQuotaHelper from 'Kubernetes/helpers/resourceQuotaHelper';
+import { getNamespaces } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
/* @ngInject */
export function KubernetesResourcePoolService(
@@ -11,7 +12,8 @@ export function KubernetesResourcePoolService(
KubernetesNamespaceService,
KubernetesResourceQuotaService,
KubernetesIngressService,
- KubernetesPortainerNamespaces
+ KubernetesPortainerNamespaces,
+ EndpointProvider
) {
return {
get,
@@ -37,9 +39,14 @@ export function KubernetesResourcePoolService(
// getting the quota for all namespaces is costly by default, so disable getting it by default
async function getAll({ getQuota = false }) {
- const namespaces = await KubernetesNamespaceService.get();
+ const namespaces = await getNamespaces(EndpointProvider.endpointID());
+ // there is a lot of downstream logic using the angular namespace type with a '.Status' field (not '.Status.phase'), so format the status here to match this logic
+ const namespacesFormattedStatus = namespaces.map((namespace) => ({
+ ...namespace,
+ Status: namespace.Status.phase,
+ }));
const pools = await Promise.all(
- _.map(namespaces, async (namespace) => {
+ _.map(namespacesFormattedStatus, async (namespace) => {
const name = namespace.Name;
const pool = KubernetesResourcePoolConverter.apiToResourcePool(namespace);
if (getQuota) {
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js
index 50c923d58..79549084a 100644
--- a/app/kubernetes/views/deploy/deployController.js
+++ b/app/kubernetes/views/deploy/deployController.js
@@ -6,13 +6,13 @@ import PortainerError from '@/portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { isTemplateVariablesEnabled, renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
-import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
-import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
-import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
+import { confirmWebEditorDiscard } from '@@/modals/confirm';
+import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
+import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
class KubernetesDeployController {
/* @ngInject */
From 993f69db37ccb6929937472eeb93b98e26b20a19 Mon Sep 17 00:00:00 2001
From: James Player
Date: Fri, 14 Mar 2025 10:37:14 +1300
Subject: [PATCH 044/212] chore(app): Migrate helm templates list to react
(#492)
---
.../helm-templates-list-item.css | 9 -
.../helm-templates-list-item.html | 40 ----
.../helm-templates-list-item.js | 17 --
.../helm-templates-list.controller.js | 43 ----
.../helm-templates-list.html | 79 --------
.../helm-templates-list.js | 14 --
.../helm/helm-templates/helm-templates.html | 2 +-
app/kubernetes/react/components/index.ts | 15 ++
.../HelmTemplates/HelmTemplatesList.test.tsx | 190 ++++++++++++++++++
.../helm/HelmTemplates/HelmTemplatesList.tsx | 179 +++++++++++++++++
.../HelmTemplates/HelmTemplatesListItem.tsx | 74 +++++++
11 files changed, 459 insertions(+), 203 deletions(-)
delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.css
delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html
delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js
delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js
delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js
create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx
create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.css b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.css
deleted file mode 100644
index a618dc68b..000000000
--- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.css
+++ /dev/null
@@ -1,9 +0,0 @@
-.helm-template-item-details {
- display: flex;
- justify-content: space-between;
- flex-wrap: wrap;
-}
-
-.helm-template-item-details .helm-template-item-details-sub {
- width: 100%;
-}
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html
deleted file mode 100644
index 43658b833..000000000
--- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.html
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ $ctrl.model.name }}
-
-
-
-
-
- Helm
-
-
-
-
-
-
-
-
- {{ $ctrl.model.description }}
-
-
- {{ $ctrl.model.annotations.category }}
-
-
-
-
-
-
-
-
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js
deleted file mode 100644
index adde64a03..000000000
--- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list-item/helm-templates-list-item.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import angular from 'angular';
-import './helm-templates-list-item.css';
-import { HelmIcon } from '../../HelmIcon';
-
-angular.module('portainer.kubernetes').component('helmTemplatesListItem', {
- templateUrl: './helm-templates-list-item.html',
- bindings: {
- model: '<',
- onSelect: '<',
- },
- transclude: {
- actions: '?templateItemActions',
- },
- controller() {
- this.fallbackIcon = HelmIcon;
- },
-});
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js
deleted file mode 100644
index 9ba2a579d..000000000
--- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.controller.js
+++ /dev/null
@@ -1,43 +0,0 @@
-export default class HelmTemplatesListController {
- /* @ngInject */
- constructor($async, $scope, HelmService, Notifications) {
- this.$async = $async;
- this.$scope = $scope;
- this.HelmService = HelmService;
- this.Notifications = Notifications;
-
- this.state = {
- textFilter: '',
- selectedCategory: '',
- categories: [],
- };
-
- this.updateCategories = this.updateCategories.bind(this);
- this.onCategoryChange = this.onCategoryChange.bind(this);
- }
-
- async updateCategories() {
- try {
- const annotationCategories = this.charts
- .map((t) => t.annotations) // get annotations
- .filter((a) => a) // filter out undefined/nulls
- .map((c) => c.category); // get annotation category
- const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
- this.state.categories = availableCategories.map((cat) => ({ label: cat, value: cat }));
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve helm charts categories');
- }
- }
-
- onCategoryChange(value) {
- return this.$scope.$evalAsync(() => {
- this.state.selectedCategory = value || '';
- });
- }
-
- $onChanges() {
- if (this.charts.length > 0) {
- this.updateCategories();
- }
- }
-}
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
deleted file mode 100644
index cd2c1131d..000000000
--- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.html
+++ /dev/null
@@ -1,79 +0,0 @@
-
-
-
-
Select the Helm chart to use. Bring further Helm charts into your selection list via
-
User settings - Helm repositories .
-
-
-
-
Disclaimer
-
- At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.
- If you would like to provide feedback on OCI support or get access to early releases to test this functionality,
-
please get in touch .
-
-
-
-
-
-
-
-
-
No Helm charts found
-
- Loading...
-
Initial download of Helm charts can take a few minutes
-
-
No helm charts available.
-
-
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js b/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js
deleted file mode 100644
index 2366e8d5a..000000000
--- a/app/kubernetes/components/helm/helm-templates/helm-templates-list/helm-templates-list.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import angular from 'angular';
-import controller from './helm-templates-list.controller';
-
-angular.module('portainer.kubernetes').component('helmTemplatesList', {
- templateUrl: './helm-templates-list.html',
- controller,
- bindings: {
- loading: '<',
- titleText: '@',
- charts: '<',
- tableKey: '@',
- selectAction: '<',
- },
-});
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.html b/app/kubernetes/components/helm/helm-templates/helm-templates.html
index a5f8dc960..4b998cf76 100644
--- a/app/kubernetes/components/helm/helm-templates/helm-templates.html
+++ b/app/kubernetes/components/helm/helm-templates/helm-templates.html
@@ -101,7 +101,7 @@
(
+
+ )),
+ user
+ )
+ );
+ return { ...render( ), user };
+}
+
+describe('HelmTemplatesList', () => {
+ beforeEach(() => {
+ selectActionMock.mockClear();
+ });
+
+ it('should display title and charts list', async () => {
+ renderComponent();
+
+ // Check for the title
+ expect(screen.getByText('Test Helm Templates')).toBeInTheDocument();
+
+ // Check for charts
+ expect(screen.getByText('test-chart-1')).toBeInTheDocument();
+ expect(screen.getByText('Test Chart 1 Description')).toBeInTheDocument();
+ expect(screen.getByText('nginx-chart')).toBeInTheDocument();
+ expect(screen.getByText('Nginx Web Server')).toBeInTheDocument();
+ });
+
+ it('should call selectAction when a chart is clicked', async () => {
+ renderComponent();
+
+ // Find the first chart item
+ const firstChartItem = screen.getByText('test-chart-1').closest('button');
+ expect(firstChartItem).not.toBeNull();
+
+ // Click on the chart item
+ if (firstChartItem) {
+ fireEvent.click(firstChartItem);
+ }
+
+ // Check if selectAction was called with the correct chart
+ expect(selectActionMock).toHaveBeenCalledWith(mockCharts[0]);
+ });
+
+ it('should filter charts by text search', async () => {
+ renderComponent();
+ const user = userEvent.setup();
+
+ // Find search input and type "nginx"
+ const searchInput = screen.getByPlaceholderText('Search...');
+ await user.type(searchInput, 'nginx');
+
+ // Wait 300ms for debounce
+ await new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(undefined);
+ }, 300);
+ });
+
+ // Should show only nginx chart
+ expect(screen.getByText('nginx-chart')).toBeInTheDocument();
+ expect(screen.queryByText('test-chart-1')).not.toBeInTheDocument();
+ expect(screen.queryByText('test-chart-2')).not.toBeInTheDocument();
+ });
+
+ it('should filter charts by category', async () => {
+ renderComponent();
+ const user = userEvent.setup();
+
+ // Find the category select
+ const categorySelect = screen.getByText('Select a category');
+ await user.click(categorySelect);
+
+ // Select "web" category
+ const webCategory = screen.getByText('web', {
+ selector: '[tabindex="-1"]',
+ });
+ await user.click(webCategory);
+
+ // Should show only web category charts
+ expect(screen.queryByText('nginx-chart')).toBeInTheDocument();
+ expect(screen.queryByText('test-chart-1')).not.toBeInTheDocument();
+ expect(screen.queryByText('test-chart-2')).not.toBeInTheDocument();
+ });
+
+ it('should show loading message when loading prop is true', async () => {
+ renderComponent({ loading: true });
+
+ // Check for loading message
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ expect(
+ screen.getByText('Initial download of Helm charts can take a few minutes')
+ ).toBeInTheDocument();
+ });
+
+ it('should show empty message when no charts are available', async () => {
+ renderComponent({ charts: [] });
+
+ // Check for empty message
+ expect(screen.getByText('No helm charts available.')).toBeInTheDocument();
+ });
+
+ it('should show no results message when search has no matches', async () => {
+ renderComponent();
+ const user = userEvent.setup();
+
+ // Find search input and type text that won't match any charts
+ const searchInput = screen.getByPlaceholderText('Search...');
+ await user.type(searchInput, 'nonexistent chart');
+
+ // Wait 300ms for debounce
+ await new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(undefined);
+ }, 300);
+ });
+
+ // Check for no results message
+ expect(screen.getByText('No Helm charts found')).toBeInTheDocument();
+ });
+
+ it('should handle keyboard navigation and selection', async () => {
+ renderComponent();
+ const user = userEvent.setup();
+
+ // Find the first chart item
+ const firstChartItem = screen.getByText('test-chart-1').closest('button');
+ expect(firstChartItem).not.toBeNull();
+
+ // Focus and press Enter
+ if (firstChartItem) {
+ (firstChartItem as HTMLElement).focus();
+ await user.keyboard('{Enter}');
+ }
+
+ // Check if selectAction was called with the correct chart
+ expect(selectActionMock).toHaveBeenCalledWith(mockCharts[0]);
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
new file mode 100644
index 000000000..5cfdb6953
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
@@ -0,0 +1,179 @@
+import { useState, useMemo } from 'react';
+
+import { PortainerSelect } from '@/react/components/form-components/PortainerSelect';
+import { Link } from '@/react/components/Link';
+
+import { InsightsBox } from '@@/InsightsBox';
+import { SearchBar } from '@@/datatables/SearchBar';
+
+import { Chart, HelmTemplatesListItem } from './HelmTemplatesListItem';
+
+interface Props {
+ loading: boolean;
+ titleText: string;
+ charts?: Chart[];
+ selectAction: (chart: Chart) => void;
+}
+
+/**
+ * Get categories from charts
+ * @param charts - The charts to get the categories from
+ * @returns Categories
+ */
+function getCategories(charts: Chart[]) {
+ const annotationCategories = charts
+ .map((chart) => chart.annotations?.category) // get category
+ .filter((c): c is string => !!c); // filter out nulls/undefined
+
+ const availableCategories = [...new Set(annotationCategories)].sort(); // unique and sort
+
+ // Create options array in the format expected by PortainerSelect
+ return availableCategories.map((cat) => ({
+ label: cat,
+ value: cat,
+ }));
+}
+
+/**
+ * Get filtered charts
+ * @param charts - The charts to get the filtered charts from
+ * @param textFilter - The text filter
+ * @param selectedCategory - The selected category
+ * @returns Filtered charts
+ */
+function getFilteredCharts(
+ charts: Chart[],
+ textFilter: string,
+ selectedCategory: string | null
+) {
+ return charts.filter((chart) => {
+ // Text filter
+ if (
+ textFilter &&
+ !chart.name.toLowerCase().includes(textFilter.toLowerCase()) &&
+ !chart.description.toLowerCase().includes(textFilter.toLowerCase())
+ ) {
+ return false;
+ }
+
+ // Category filter
+ if (
+ selectedCategory &&
+ (!chart.annotations || chart.annotations.category !== selectedCategory)
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+}
+
+export function HelmTemplatesList({
+ loading,
+ titleText,
+ charts = [],
+ selectAction,
+}: Props) {
+ const [textFilter, setTextFilter] = useState('');
+ const [selectedCategory, setSelectedCategory] = useState(null);
+
+ const categories = useMemo(() => getCategories(charts), [charts]);
+
+ const filteredCharts = useMemo(
+ () => getFilteredCharts(charts, textFilter, selectedCategory),
+ [charts, textFilter, selectedCategory]
+ );
+
+ return (
+
+
+
{titleText}
+
+
setTextFilter(value)}
+ placeholder="Search..."
+ data-cy="helm-templates-search"
+ className="!mr-0 h-9"
+ />
+
+
+
setSelectedCategory(value)}
+ isClearable
+ bindToBody
+ data-cy="helm-category-select"
+ />
+
+
+
+
+ Select the Helm chart to use. Bring further Helm charts into your
+ selection list via{' '}
+
+ User settings - Helm repositories
+
+ .
+
+
+
+ At present Portainer does not support OCI format Helm charts.
+ Support for OCI charts will be available in a future release.
+
+ If you would like to provide feedback on OCI support or get access
+ to early releases to test this functionality,{' '}
+
+ please get in touch
+
+ .
+ >
+ }
+ />
+
+
+
+ {filteredCharts.map((chart) => (
+
+ ))}
+
+ {filteredCharts.length === 0 && textFilter && (
+
No Helm charts found
+ )}
+
+ {loading && (
+
+ Loading...
+
+ Initial download of Helm charts can take a few minutes
+
+
+ )}
+
+ {!loading && charts.length === 0 && (
+
+ No helm charts available.
+
+ )}
+
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx
new file mode 100644
index 000000000..b51fb5cdd
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+
+import { HelmIcon } from '@/kubernetes/components/helm/helm-templates/HelmIcon';
+import { FallbackImage } from '@/react/components/FallbackImage';
+
+import Svg from '@@/Svg';
+
+export interface Chart {
+ name: string;
+ description: string;
+ icon?: string;
+ annotations?: {
+ category?: string;
+ };
+}
+
+interface HelmTemplatesListItemProps {
+ model: Chart;
+ onSelect: (model: Chart) => void;
+ actions?: React.ReactNode;
+}
+
+export function HelmTemplatesListItem(props: HelmTemplatesListItemProps) {
+ const { model, onSelect, actions } = props;
+
+ function handleSelect() {
+ onSelect(model);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {model.name}
+
+
+
+
+ Helm
+
+
+
+
+
{actions}
+
+
+ {model.description}
+ {model.annotations?.category && (
+
+ {model.annotations.category}
+
+ )}
+
+
+
+
+ );
+}
From 66bcf9223a1506f146065cffd3928612ca81345f Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Fri, 14 Mar 2025 11:20:41 +1300
Subject: [PATCH 045/212] fix(k8s/config): avoid hardcoded
"insecure-skip-tls-verify" in kubeconfig [BE-11651] (#500)
---
api/http/handler/kubernetes/config.go | 69 +++++++-
api/http/handler/kubernetes/config_test.go | 186 +++++++++++++++++++++
2 files changed, 254 insertions(+), 1 deletion(-)
create mode 100644 api/http/handler/kubernetes/config_test.go
diff --git a/api/http/handler/kubernetes/config.go b/api/http/handler/kubernetes/config.go
index aad9c03a8..a42f98ee3 100644
--- a/api/http/handler/kubernetes/config.go
+++ b/api/http/handler/kubernetes/config.go
@@ -1,9 +1,14 @@
package kubernetes
import (
+ "crypto/x509"
+ "encoding/pem"
"errors"
"fmt"
"net/http"
+ "net/url"
+ "os"
+ "strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
@@ -162,11 +167,38 @@ func (handler *Handler) buildConfig(r *http.Request, tokenData *portainer.TokenD
func (handler *Handler) buildCluster(r *http.Request, endpoint portainer.Endpoint, isInternal bool) clientV1.NamedCluster {
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(r.Host, endpoint.ID, isInternal)
+ selfSignedCert := false
+ serverUrl, err := url.Parse(kubeConfigInternal.ClusterServerURL)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to parse server URL")
+ }
+
+ if strings.EqualFold(serverUrl.Scheme, "https") {
+ var certPem []byte
+ var err error
+
+ if kubeConfigInternal.CertificateAuthorityData != "" {
+ certPem = []byte(kubeConfigInternal.CertificateAuthorityData)
+ } else if kubeConfigInternal.CertificateAuthorityFile != "" {
+ certPem, err = os.ReadFile(kubeConfigInternal.CertificateAuthorityFile)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to open certificate file")
+ }
+ }
+
+ if certPem != nil {
+ selfSignedCert, err = IsSelfSignedCertificate(certPem)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to verify if certificate is self-signed")
+ }
+ }
+ }
+
return clientV1.NamedCluster{
Name: buildClusterName(endpoint.Name),
Cluster: clientV1.Cluster{
Server: kubeConfigInternal.ClusterServerURL,
- InsecureSkipTLSVerify: true,
+ InsecureSkipTLSVerify: selfSignedCert,
},
}
}
@@ -215,3 +247,38 @@ func writeFileContent(w http.ResponseWriter, r *http.Request, endpoints []portai
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.json", filenameBase))
return response.JSON(w, config)
}
+
+func IsSelfSignedCertificate(certPem []byte) (bool, error) {
+ if certPem == nil {
+ return false, errors.New("certificate data is empty")
+ }
+
+ if !strings.Contains(string(certPem), "BEGIN CERTIFICATE") {
+ certPem = []byte(fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----", string(certPem)))
+ }
+
+ block, _ := pem.Decode(certPem)
+ if block == nil {
+ return false, errors.New("failed to decode certificate")
+ }
+
+ cert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ return false, err
+ }
+
+ if cert.Issuer.String() != cert.Subject.String() {
+ return false, nil
+ }
+
+ roots := x509.NewCertPool()
+ roots.AddCert(cert)
+
+ opts := x509.VerifyOptions{
+ Roots: roots,
+ CurrentTime: cert.NotBefore,
+ }
+
+ _, err = cert.Verify(opts)
+ return err == nil, err
+}
diff --git a/api/http/handler/kubernetes/config_test.go b/api/http/handler/kubernetes/config_test.go
new file mode 100644
index 000000000..46875a460
--- /dev/null
+++ b/api/http/handler/kubernetes/config_test.go
@@ -0,0 +1,186 @@
+package kubernetes
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsSelfSignedCertificate(t *testing.T) {
+
+ tc := []struct {
+ name string
+ cert string
+ expected bool
+ }{
+ {
+ name: "portainer self-signed",
+ cert: `-----BEGIN CERTIFICATE-----
+MIIBUTCB+KADAgECAhBB7psNiJlJd/nRCCKUPVenMAoGCCqGSM49BAMCMAAwHhcN
+MjUwMzEzMDQwODI0WhcNMzAwMzEzMDQwODI0WjAAMFkwEwYHKoZIzj0CAQYIKoZI
+zj0DAQcDQgAESdGCaXq0r1GDxF89yKjjLeCIixiPDdXAg+lw4NqAWeJq2AOo+8IH
+vcCq9bSlYlezK8RzTsbf9Z1m5jRqUEbSjqNUMFIwDgYDVR0PAQH/BAQDAgWgMBMG
+A1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0RAQH/BBMwEYIJ
+bG9jYWxob3N0hwQAAAAAMAoGCCqGSM49BAMCA0gAMEUCIApLliukFaCZHbc/2pkH
+0VDY+fBMb12jhmVpgKh1Cqg9AiEAwFrMQLUkzATUpiHuukdUg5VsUiMIkWTPLglz
+E4+1dRc=
+-----END CERTIFICATE-----
+`,
+ expected: true,
+ },
+ {
+ name: "portainer self-signed without header",
+ cert: `MIIBUzCB+aADAgECAhEAjsskPzuCS5BeHjXGwYqc2jAKBggqhkjOPQQDAjAAMB4XDTI1MDMxMzA0MzQyNloXDTMwMDMxMzA0MzQyNlowADBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABITD+dNDLYQbLYDE3UMlTzD61OYRSVkVZspdp1MvZITIG4VOxtfQUqcW3P7OHQdoi52GIQ/GM6iDgxwB1BOyi3mjVDBSMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdEQEB/wQTMBGCCWxvY2FsaG9zdIcEAAAAADAKBggqhkjOPQQDAgNJADBGAiEA8SmyeYLhrnrNLAFcxZp0dk6nMN70XVAfqGnbK/s8NR8CIQDgQdqhfge8QvN2TsH4gg98a9VHDv+RlcOlJ80SS+G/Ww==`,
+ expected: true,
+ },
+ {
+ name: "custom certificate generated by openssl",
+ cert: `-----BEGIN CERTIFICATE-----
+MIIB9TCCAZugAwIBAgIULTkNYfYHiqfOiX7mKOIGxRefx/YwCgYIKoZIzj0EAwIw
+SDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNp
+c2NvMRQwEgYDVQQDEwtleGFtcGxlLm5ldDAeFw0yNTAyMjgwNjI3MDBaFw0zNTAy
+MjYwNjI3MDBaMAAwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAT3WlLvbGw7wPkQ
+3LuHFJEaNrDv3n359JMV1CkjQi3U37u0fJrjd+8o7TxPBYgt9HDD9vsURhy41DNo
+g71F2AIto4GqMIGnMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcD
+AQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQU+nMxx/VCE9fzrlHI
+FX9mF5SRPrkwHwYDVR0jBBgwFoAUOlUIToGwnBOqzZ1dBfOvdKbwNaAwKAYDVR0R
+AQH/BB4wHIIaZWRnZS4xNzIuMTcuMjIxLjIwOC5uaXAuaW8wCgYIKoZIzj0EAwID
+SAAwRQIgeYrkjY0z/ypMKXZbvbMi8qOK44qoISKkSErBUCBLuwoCIQDRaJA9r931
+utpXXnysVGecVXHHKOOl1YhWglmuPvcZhw==
+-----END CERTIFICATE-----`,
+ expected: false,
+ },
+ {
+ name: "google.com certificate",
+ cert: `-----BEGIN CERTIFICATE-----
+MIIOITCCDQmgAwIBAgIQKS0IQxknY8USDjt3IYchljANBgkqhkiG9w0BAQsFADA7
+MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZpY2VzMQww
+CgYDVQQDEwNXUjIwHhcNMjUwMjI2MTUzMjU1WhcNMjUwNTIxMTUzMjU0WjAXMRUw
+EwYDVQQDDAwqLmdvb2dsZS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARx
+nMOmIG3BuO7my/BbF/rGPAMH/JbxBDufbYFQHV+6l5pF5sdT/Zov3X+qsR3IYFl7
+F2a0gAUmK1Bq7//zTb3uo4IMDjCCDAowDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQM
+MAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFN+aEjBz3PaUtelz
+3g9rVTkGRgU0MB8GA1UdIwQYMBaAFN4bHu15FdQ+NyTDIbvsNDltQrIwMFgGCCsG
+AQUFBwEBBEwwSjAhBggrBgEFBQcwAYYVaHR0cDovL28ucGtpLmdvb2cvd3IyMCUG
+CCsGAQUFBzAChhlodHRwOi8vaS5wa2kuZ29vZy93cjIuY3J0MIIJ5AYDVR0RBIIJ
+2zCCCdeCDCouZ29vZ2xlLmNvbYIWKi5hcHBlbmdpbmUuZ29vZ2xlLmNvbYIJKi5i
+ZG4uZGV2ghUqLm9yaWdpbi10ZXN0LmJkbi5kZXaCEiouY2xvdWQuZ29vZ2xlLmNv
+bYIYKi5jcm93ZHNvdXJjZS5nb29nbGUuY29tghgqLmRhdGFjb21wdXRlLmdvb2ds
+ZS5jb22CCyouZ29vZ2xlLmNhggsqLmdvb2dsZS5jbIIOKi5nb29nbGUuY28uaW6C
+DiouZ29vZ2xlLmNvLmpwgg4qLmdvb2dsZS5jby51a4IPKi5nb29nbGUuY29tLmFy
+gg8qLmdvb2dsZS5jb20uYXWCDyouZ29vZ2xlLmNvbS5icoIPKi5nb29nbGUuY29t
+LmNvgg8qLmdvb2dsZS5jb20ubXiCDyouZ29vZ2xlLmNvbS50coIPKi5nb29nbGUu
+Y29tLnZuggsqLmdvb2dsZS5kZYILKi5nb29nbGUuZXOCCyouZ29vZ2xlLmZyggsq
+Lmdvb2dsZS5odYILKi5nb29nbGUuaXSCCyouZ29vZ2xlLm5sggsqLmdvb2dsZS5w
+bIILKi5nb29nbGUucHSCDyouZ29vZ2xlYXBpcy5jboIRKi5nb29nbGV2aWRlby5j
+b22CDCouZ3N0YXRpYy5jboIQKi5nc3RhdGljLWNuLmNvbYIPZ29vZ2xlY25hcHBz
+LmNughEqLmdvb2dsZWNuYXBwcy5jboIRZ29vZ2xlYXBwcy1jbi5jb22CEyouZ29v
+Z2xlYXBwcy1jbi5jb22CDGdrZWNuYXBwcy5jboIOKi5na2VjbmFwcHMuY26CEmdv
+b2dsZWRvd25sb2Fkcy5jboIUKi5nb29nbGVkb3dubG9hZHMuY26CEHJlY2FwdGNo
+YS5uZXQuY26CEioucmVjYXB0Y2hhLm5ldC5jboIQcmVjYXB0Y2hhLWNuLm5ldIIS
+Ki5yZWNhcHRjaGEtY24ubmV0ggt3aWRldmluZS5jboINKi53aWRldmluZS5jboIR
+YW1wcHJvamVjdC5vcmcuY26CEyouYW1wcHJvamVjdC5vcmcuY26CEWFtcHByb2pl
+Y3QubmV0LmNughMqLmFtcHByb2plY3QubmV0LmNughdnb29nbGUtYW5hbHl0aWNz
+LWNuLmNvbYIZKi5nb29nbGUtYW5hbHl0aWNzLWNuLmNvbYIXZ29vZ2xlYWRzZXJ2
+aWNlcy1jbi5jb22CGSouZ29vZ2xlYWRzZXJ2aWNlcy1jbi5jb22CEWdvb2dsZXZh
+ZHMtY24uY29tghMqLmdvb2dsZXZhZHMtY24uY29tghFnb29nbGVhcGlzLWNuLmNv
+bYITKi5nb29nbGVhcGlzLWNuLmNvbYIVZ29vZ2xlb3B0aW1pemUtY24uY29tghcq
+Lmdvb2dsZW9wdGltaXplLWNuLmNvbYISZG91YmxlY2xpY2stY24ubmV0ghQqLmRv
+dWJsZWNsaWNrLWNuLm5ldIIYKi5mbHMuZG91YmxlY2xpY2stY24ubmV0ghYqLmcu
+ZG91YmxlY2xpY2stY24ubmV0gg5kb3VibGVjbGljay5jboIQKi5kb3VibGVjbGlj
+ay5jboIUKi5mbHMuZG91YmxlY2xpY2suY26CEiouZy5kb3VibGVjbGljay5jboIR
+ZGFydHNlYXJjaC1jbi5uZXSCEyouZGFydHNlYXJjaC1jbi5uZXSCHWdvb2dsZXRy
+YXZlbGFkc2VydmljZXMtY24uY29tgh8qLmdvb2dsZXRyYXZlbGFkc2VydmljZXMt
+Y24uY29tghhnb29nbGV0YWdzZXJ2aWNlcy1jbi5jb22CGiouZ29vZ2xldGFnc2Vy
+dmljZXMtY24uY29tghdnb29nbGV0YWdtYW5hZ2VyLWNuLmNvbYIZKi5nb29nbGV0
+YWdtYW5hZ2VyLWNuLmNvbYIYZ29vZ2xlc3luZGljYXRpb24tY24uY29tghoqLmdv
+b2dsZXN5bmRpY2F0aW9uLWNuLmNvbYIkKi5zYWZlZnJhbWUuZ29vZ2xlc3luZGlj
+YXRpb24tY24uY29tghZhcHAtbWVhc3VyZW1lbnQtY24uY29tghgqLmFwcC1tZWFz
+dXJlbWVudC1jbi5jb22CC2d2dDEtY24uY29tgg0qLmd2dDEtY24uY29tggtndnQy
+LWNuLmNvbYINKi5ndnQyLWNuLmNvbYILMm1kbi1jbi5uZXSCDSouMm1kbi1jbi5u
+ZXSCFGdvb2dsZWZsaWdodHMtY24ubmV0ghYqLmdvb2dsZWZsaWdodHMtY24ubmV0
+ggxhZG1vYi1jbi5jb22CDiouYWRtb2ItY24uY29tghRnb29nbGVzYW5kYm94LWNu
+LmNvbYIWKi5nb29nbGVzYW5kYm94LWNuLmNvbYIeKi5zYWZlbnVwLmdvb2dsZXNh
+bmRib3gtY24uY29tgg0qLmdzdGF0aWMuY29tghQqLm1ldHJpYy5nc3RhdGljLmNv
+bYIKKi5ndnQxLmNvbYIRKi5nY3BjZG4uZ3Z0MS5jb22CCiouZ3Z0Mi5jb22CDiou
+Z2NwLmd2dDIuY29tghAqLnVybC5nb29nbGUuY29tghYqLnlvdXR1YmUtbm9jb29r
+aWUuY29tggsqLnl0aW1nLmNvbYILYW5kcm9pZC5jb22CDSouYW5kcm9pZC5jb22C
+EyouZmxhc2guYW5kcm9pZC5jb22CBGcuY26CBiouZy5jboIEZy5jb4IGKi5nLmNv
+ggZnb28uZ2yCCnd3dy5nb28uZ2yCFGdvb2dsZS1hbmFseXRpY3MuY29tghYqLmdv
+b2dsZS1hbmFseXRpY3MuY29tggpnb29nbGUuY29tghJnb29nbGVjb21tZXJjZS5j
+b22CFCouZ29vZ2xlY29tbWVyY2UuY29tgghnZ3BodC5jboIKKi5nZ3BodC5jboIK
+dXJjaGluLmNvbYIMKi51cmNoaW4uY29tggh5b3V0dS5iZYILeW91dHViZS5jb22C
+DSoueW91dHViZS5jb22CEW11c2ljLnlvdXR1YmUuY29tghMqLm11c2ljLnlvdXR1
+YmUuY29tghR5b3V0dWJlZWR1Y2F0aW9uLmNvbYIWKi55b3V0dWJlZWR1Y2F0aW9u
+LmNvbYIPeW91dHViZWtpZHMuY29tghEqLnlvdXR1YmVraWRzLmNvbYIFeXQuYmWC
+ByoueXQuYmWCGmFuZHJvaWQuY2xpZW50cy5nb29nbGUuY29tghMqLmFuZHJvaWQu
+Z29vZ2xlLmNughIqLmNocm9tZS5nb29nbGUuY26CFiouZGV2ZWxvcGVycy5nb29n
+bGUuY26CFSouYWlzdHVkaW8uZ29vZ2xlLmNvbTATBgNVHSAEDDAKMAgGBmeBDAEC
+ATA2BgNVHR8ELzAtMCugKaAnhiVodHRwOi8vYy5wa2kuZ29vZy93cjIvb0JGWVlh
+aHpnVkkuY3JsMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHcAzxFW7tUufK/zh1vZ
+aS6b6RpxZ0qwF+ysAdJbd87MOwgAAAGVQxqxaQAABAMASDBGAiEAk6r74vfyJIaa
+hYTWqNRsjl/RpCWq/wyzzMi21zgGmfkCIQCZafyS/fl0tiutICL9aOSnDBRfPYqd
+CeNqKOy11EjvigB1AN6FgddQJHxrzcuvVjfF54HGTORu1hdjn480pybJ4r03AAAB
+lUMasUkAAAQDAEYwRAIgYfG2iyRnmn8MI86RFDxOQW1/IOBAjQxNfIQ8toZlZkoC
+IA1BHw7cqmlTP7Ks+ebX6hGfNlVsgTQS8iYyKL5/BSvTMA0GCSqGSIb3DQEBCwUA
+A4IBAQAYSNtoW72rqhPfjV5Ug1ENbbimfqmqiJS4JdzaEFRpftzachTuvx8relaY
++7FAz5y4YULu9LGNjpBRYW8yW9pgfWyc53CCHSkDODguUOMCRo3hdglxZ2d5pJ/8
+TQY4zRBd8OHzOAx2kH6jLEj9I0nDie3vowSYm7FCBRLjzfForRNQWmzPu+5hS3De
+QM0R2jWpmPcG3ffQ5qQwnAQnP9HCK9oEZ5cFqLvOQWfttj/rzKOz856iSEoRpf8S
+wVFRu3Uv2TXQ6UYF2cDfiWCe6/mO35CIynC6FVkunze/Q/2rtaCDttLRYZcLllj8
+PSl7nmLhtqDlO7da/S34BFiyyRjN
+-----END CERTIFICATE-----
+`,
+ expected: false,
+ },
+ {
+ name: "let's encrypt certificate",
+ cert: `-----BEGIN CERTIFICATE-----
+MIIGMjCCBRqgAwIBAgISBVHH05rEMkaCuDQvABDjiam0MA0GCSqGSIb3DQEBCwUA
+MDMxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQwwCgYDVQQD
+EwNSMTAwHhcNMjUwMzEzMDIyMzE2WhcNMjUwNjExMDIyMzE1WjAkMSIwIAYDVQQD
+Exlvei1kZW1vLnBvcnRhaW5lcmNsb3VkLmlvMIICIjANBgkqhkiG9w0BAQEFAAOC
+Ag8AMIICCgKCAgEAwNCcr9azSaldEwgL54bQScuWBnmw3FMHgEATxDVp2MEawQkV
+I3VScUcJWBnlHlb7TUanRC/c/vJGbzc+KDuCRTZ2/Ob2yQ9G5mZjGttBAnBSQPpV
+arEEBFCClhVBn4LhLNmIsCjCy25+m0HY/dwWbKjTMT/KxpTa3L3mdmIFa7XNs6W2
+vEZGwYM+2JPMJ9DwemVrrrvRqd5vLWTZcWvWJQ7HMfw3PoELpeqyycmxDqd9PCMz
+yMp8q3UwLDur3+KfDXGtGOoubxcOuJrpemOe8JeM5cEYEhvOy8D16zmWwWYDT19D
+ElFfUbM0GGITpJ41Qie03DvmI0hDYDqTEZfKza967VsvD7K9bFgLHmHdv7gLNutB
+FConpziNqslapWwQ5j7bKircxKjRQVkOiXH48m2IUzylqWgJPVMvHukRu0YVnvbt
+Q53xNVZQEbjvZmIuz8jqo22Y/1Jr7Plnb1lUvvDznA58MHT0KA4LSZwk9tvMJJCw
+vh7AoWB6/Jnl8QVnApOdCa6M/An128rBwgrCmp0wSvhMecTkWC8/gsah0Q5wKFL3
+ziBth728Qy8RlNghRUw88e/y4pdGHN8egjK1NpdgsvTFdRNQ8qwu0lx9pO3b6TNQ
+qDG5pirXjS/DhPYvZtJRDK6SMTHJNm+0NGdWB8qpNssFrU6u2cRl0533LtECAwEA
+AaOCAk0wggJJMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
+KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUiQi/3pZamfPxRGPI8DTZ
+tej1494wHwYDVR0jBBgwFoAUu7zDR6XkvKnGw6RyDBCNojXhyOgwVwYIKwYBBQUH
+AQEESzBJMCIGCCsGAQUFBzABhhZodHRwOi8vcjEwLm8ubGVuY3Iub3JnMCMGCCsG
+AQUFBzAChhdodHRwOi8vcjEwLmkubGVuY3Iub3JnLzAkBgNVHREEHTAbghlvei1k
+ZW1vLnBvcnRhaW5lcmNsb3VkLmlvMBMGA1UdIAQMMAowCAYGZ4EMAQIBMC4GA1Ud
+HwQnMCUwI6AhoB+GHWh0dHA6Ly9yMTAuYy5sZW5jci5vcmcvNTMuY3JsMIIBBAYK
+KwYBBAHWeQIEAgSB9QSB8gDwAHcAzPsPaoVxCWX+lZtTzumyfCLphVwNl422qX5U
+wP5MDbAAAAGVjYW7/QAABAMASDBGAiEA8CjMOIj7wqQ60BX22A5pDkA23IxZPzwV
+1MF5+VSgdqgCIQCZhry5AK2VyZX/cIODEl6eHBCUWS4vHB+J8RxeclKCpAB1AKLj
+CuRF772tm3447Udnd1PXgluElNcrXhssxLlQpEfnAAABlY2Fu/QAAAQDAEYwRAIg
+bwjJgZJew/1LoL9yzDD1P4Xkd8ezFucxfU3AzlV1XEYCIH5RPyW1HP9GSr+aAx+I
+o3inVl1NagJFYiApAPvFmIEgMA0GCSqGSIb3DQEBCwUAA4IBAQATJWi1sJSBstO+
+hyH7DsrAtDhiQTOWzUZezBlgCn8hfmA3nX5uKsHyxPPPEQ/GFYOltRD/+34X9kFF
+YNzUjJOP0bGk45I1JbspxRRvtbDpk0+dj2VE2toM8vLRDz3+DB4YB2lFofYlex++
+16xFzOIE+ZW41qBs3G8InsyHADsaFY2CQ9re/kZvenptU/ax1U2a21JJ3TT2DmXW
+AHZYQ5/whVIowsebw1e28I12VhLl2BKn7v4MpCn3GUzBBQAEbJ6TIjHtFKWWnVfH
+FisaUX6N4hMzGZVJOsbH4QVBGuNwUshHiD8MSpbans2w+T4bCe11XayerqxFhTao
+w/pjiPVy
+-----END CERTIFICATE-----
+`,
+ expected: false,
+ },
+ }
+
+ for _, tt := range tc {
+ t.Run(tt.name, func(t *testing.T) {
+ actual, err := IsSelfSignedCertificate([]byte(tt.cert))
+ assert.NoError(t, err)
+ assert.Equal(t, tt.expected, actual)
+ })
+ }
+}
From 2c05496962c73026fd5246ef784440074004fa58 Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Sat, 15 Mar 2025 10:09:22 -0300
Subject: [PATCH 046/212] feat(edgeconfigs): parse .env config files for
interpolation BE-11673 (#514)
---
api/filesystem/serialize_per_dev_configs.go | 33 +++--
.../serialize_per_dev_configs_test.go | 133 ++++++++++--------
2 files changed, 96 insertions(+), 70 deletions(-)
diff --git a/api/filesystem/serialize_per_dev_configs.go b/api/filesystem/serialize_per_dev_configs.go
index 55881885a..7ae653934 100644
--- a/api/filesystem/serialize_per_dev_configs.go
+++ b/api/filesystem/serialize_per_dev_configs.go
@@ -15,15 +15,19 @@ type MultiFilterArgs []struct {
}
// MultiFilterDirForPerDevConfigs filers the given dirEntries with multiple filter args, returns the merged entries for the given device
-func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) []DirEntry {
+func MultiFilterDirForPerDevConfigs(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs) ([]DirEntry, []string) {
var filteredDirEntries []DirEntry
+ var envFiles []string
+
for _, multiFilterArg := range multiFilterArgs {
- tmp := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
+ tmp, efs := FilterDirForPerDevConfigs(dirEntries, multiFilterArg.FilterKey, configPath, multiFilterArg.FilterType)
filteredDirEntries = append(filteredDirEntries, tmp...)
+
+ envFiles = append(envFiles, efs...)
}
- return deduplicate(filteredDirEntries)
+ return deduplicate(filteredDirEntries), envFiles
}
func deduplicate(dirEntries []DirEntry) []DirEntry {
@@ -32,8 +36,7 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
marks := make(map[string]struct{})
for _, dirEntry := range dirEntries {
- _, ok := marks[dirEntry.Name]
- if !ok {
+ if _, ok := marks[dirEntry.Name]; !ok {
marks[dirEntry.Name] = struct{}{}
deduplicatedDirEntries = append(deduplicatedDirEntries, dirEntry)
}
@@ -50,20 +53,25 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
// 3. For filterType dir:
// dir entry: A/B/C/
// all entries: A/B/C//*
-func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) []DirEntry {
+func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) ([]DirEntry, []string) {
var filteredDirEntries []DirEntry
+ var envFiles []string
+
for _, dirEntry := range dirEntries {
if shouldIncludeEntry(dirEntry, deviceName, configPath, filterType) {
filteredDirEntries = append(filteredDirEntries, dirEntry)
+
+ if shouldParseEnvVars(dirEntry, deviceName, configPath, filterType) {
+ envFiles = append(envFiles, dirEntry.Name)
+ }
}
}
- return filteredDirEntries
+ return filteredDirEntries, envFiles
}
func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
-
// Include all entries outside of dir A
if !isInConfigDir(dirEntry, configPath) {
return true
@@ -120,6 +128,15 @@ func shouldIncludeDir(dirEntry DirEntry, deviceName, configPath string) bool {
return strings.HasPrefix(dirEntry.Name, filterPrefix)
}
+func shouldParseEnvVars(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
+ if !dirEntry.IsFile {
+ return false
+ }
+
+ return isInConfigDir(dirEntry, configPath) &&
+ filepath.Base(dirEntry.Name) == deviceName+".env"
+}
+
func appendTailSeparator(path string) string {
return fmt.Sprintf("%s%c", path, os.PathSeparator)
}
diff --git a/api/filesystem/serialize_per_dev_configs_test.go b/api/filesystem/serialize_per_dev_configs_test.go
index 55a1a4643..2330db68d 100644
--- a/api/filesystem/serialize_per_dev_configs_test.go
+++ b/api/filesystem/serialize_per_dev_configs_test.go
@@ -4,14 +4,17 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
+
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
- type args struct {
- dirEntries []DirEntry
- configPath string
- multiFilterArgs MultiFilterArgs
+ f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantDirEntries []DirEntry) {
+ t.Helper()
+
+ dirEntries, _ = MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
+ require.Equal(t, wantDirEntries, dirEntries)
}
baseDirEntries := []DirEntry{
@@ -26,69 +29,75 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
{"configs/folder2/config2", "", true, 420},
}
- tests := []struct {
- name string
- args args
- want []DirEntry
- }{
- {
- name: "filter file1",
- args: args{
- baseDirEntries,
- "configs",
- MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
- },
- want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
+ // Filter file1
+ f(
+ baseDirEntries,
+ "configs",
+ MultiFilterArgs{{"file1", portainer.PerDevConfigsTypeFile}},
+ []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3]},
+ )
+
+ // Filter folder1
+ f(
+ baseDirEntries,
+ "configs",
+ MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
+ []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
+ )
+
+ // Filter file1 and folder1
+ f(
+ baseDirEntries,
+ "configs",
+ MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
+ []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
+ )
+
+ // Filter file1 and file2
+ f(
+ baseDirEntries,
+ "configs",
+ MultiFilterArgs{
+ {"file1", portainer.PerDevConfigsTypeFile},
+ {"file2", portainer.PerDevConfigsTypeFile},
},
- {
- name: "filter folder1",
- args: args{
- baseDirEntries,
- "configs",
- MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
- },
- want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
- },
- {
- name: "filter file1 and folder1",
- args: args{
- baseDirEntries,
- "configs",
- MultiFilterArgs{{"folder1", portainer.PerDevConfigsTypeDir}},
- },
- want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6]},
- },
- {
- name: "filter file1 and file2",
- args: args{
- baseDirEntries,
- "configs",
- MultiFilterArgs{
- {"file1", portainer.PerDevConfigsTypeFile},
- {"file2", portainer.PerDevConfigsTypeFile},
- },
- },
- want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
- },
- {
- name: "filter folder1 and folder2",
- args: args{
- baseDirEntries,
- "configs",
- MultiFilterArgs{
- {"folder1", portainer.PerDevConfigsTypeDir},
- {"folder2", portainer.PerDevConfigsTypeDir},
- },
- },
- want: []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8]},
+ []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[3], baseDirEntries[4]},
+ )
+
+ // Filter folder1 and folder2
+ f(
+ baseDirEntries,
+ "configs",
+ MultiFilterArgs{
+ {"folder1", portainer.PerDevConfigsTypeDir},
+ {"folder2", portainer.PerDevConfigsTypeDir},
},
+ []DirEntry{baseDirEntries[0], baseDirEntries[1], baseDirEntries[2], baseDirEntries[5], baseDirEntries[6], baseDirEntries[7], baseDirEntries[8]},
+ )
+}
+
+func TestMultiFilterDirForPerDevConfigsEnvFiles(t *testing.T) {
+ f := func(dirEntries []DirEntry, configPath string, multiFilterArgs MultiFilterArgs, wantEnvFiles []string) {
+ t.Helper()
+
+ _, envFiles := MultiFilterDirForPerDevConfigs(dirEntries, configPath, multiFilterArgs)
+ require.Equal(t, wantEnvFiles, envFiles)
}
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- assert.Equalf(t, tt.want, MultiFilterDirForPerDevConfigs(tt.args.dirEntries, tt.args.configPath, tt.args.multiFilterArgs), "MultiFilterDirForPerDevConfigs(%v, %v, %v)", tt.args.dirEntries, tt.args.configPath, tt.args.multiFilterArgs)
- })
+ baseDirEntries := []DirEntry{
+ {".env", "", true, 420},
+ {"docker-compose.yaml", "", true, 420},
+ {"configs", "", false, 420},
+ {"configs/edge-id/edge-id.env", "", true, 420},
}
+
+ f(
+ baseDirEntries,
+ "configs",
+ MultiFilterArgs{{"edge-id", portainer.PerDevConfigsTypeDir}},
+ []string{"configs/edge-id/edge-id.env"},
+ )
+
}
func TestIsInConfigDir(t *testing.T) {
From e1f9b69cd562538e46ce2d7d5a4a32851007f37e Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Sat, 15 Mar 2025 10:10:17 -0300
Subject: [PATCH 047/212] feat(edgestack): improve the structure to make JSON
operations faster BE-11668 (#475)
---
api/datastore/migrator/migrate_dbversion100.go | 4 ++++
api/datastore/migrator/migrate_dbversion80.go | 8 ++++++--
api/portainer.go | 16 ++++++++--------
3 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/api/datastore/migrator/migrate_dbversion100.go b/api/datastore/migrator/migrate_dbversion100.go
index 458c10c95..59b15b08d 100644
--- a/api/datastore/migrator/migrate_dbversion100.go
+++ b/api/datastore/migrator/migrate_dbversion100.go
@@ -94,6 +94,10 @@ func (m *Migrator) updateEdgeStackStatusForDB100() error {
continue
}
+ if environmentStatus.Details == nil {
+ continue
+ }
+
statusArray := []portainer.EdgeStackDeploymentStatus{}
if environmentStatus.Details.Pending {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
diff --git a/api/datastore/migrator/migrate_dbversion80.go b/api/datastore/migrator/migrate_dbversion80.go
index 77671745e..a738e5177 100644
--- a/api/datastore/migrator/migrate_dbversion80.go
+++ b/api/datastore/migrator/migrate_dbversion80.go
@@ -75,6 +75,10 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
for _, edgeStack := range edgeStacks {
for endpointId, status := range edgeStack.Status {
+ if status.Details == nil {
+ status.Details = &portainer.EdgeStackStatusDetails{}
+ }
+
switch status.Type {
case portainer.EdgeStackStatusPending:
status.Details.Pending = true
@@ -93,10 +97,10 @@ func (m *Migrator) updateEdgeStackStatusForDB80() error {
edgeStack.Status[endpointId] = status
}
- err = m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack)
- if err != nil {
+ if err := m.edgeStackService.UpdateEdgeStack(edgeStack.ID, &edgeStack); err != nil {
return err
}
}
+
return nil
}
diff --git a/api/portainer.go b/api/portainer.go
index 5b7d177da..60d09a993 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -309,7 +309,7 @@ type (
// FileVersion is the version of the stack file, used to detect changes
FileVersion int `json:"FileVersion"`
// ConfigHash is the commit hash of the git repository used for deploying the stack
- ConfigHash string `json:"ConfigHash"`
+ ConfigHash string `json:"ConfigHash,omitempty"`
}
// EdgeStack represents an edge stack
@@ -353,24 +353,24 @@ type (
// EE only feature
DeploymentInfo StackDeploymentInfo
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
- ReadyRePullImage bool
+ ReadyRePullImage bool `json:"ReadyRePullImage,omitempty"`
// Deprecated
- Details EdgeStackStatusDetails
+ Details *EdgeStackStatusDetails `json:"Details,omitempty"`
// Deprecated
- Error string
+ Error string `json:"Error,omitempty"`
// Deprecated
- Type EdgeStackStatusType `json:"Type"`
+ Type EdgeStackStatusType `json:"Type,omitempty"`
}
// EdgeStackDeploymentStatus represents an edge stack deployment status
EdgeStackDeploymentStatus struct {
Time int64
Type EdgeStackStatusType
- Error string
+ Error string `json:"Error,omitempty"`
// EE only feature
- RollbackTo *int
- Version int `json:"Version,omitempty"`
+ RollbackTo *int `json:"RollbackTo,omitempty"`
+ Version int `json:"Version,omitempty"`
}
// EdgeStackStatusType represents an edge stack status type
From 2791bd123cdc16495f9a018da5ee2ece711cdfc7 Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Mon, 17 Mar 2025 12:24:39 +1300
Subject: [PATCH 048/212] fix: cve-2025-22869 develop (#511)
---
binary-version.json | 4 ++--
go.mod | 10 +++++-----
go.sum | 20 ++++++++++----------
3 files changed, 17 insertions(+), 17 deletions(-)
diff --git a/binary-version.json b/binary-version.json
index ff017d023..8b37fa345 100644
--- a/binary-version.json
+++ b/binary-version.json
@@ -1,6 +1,6 @@
{
"docker": "v27.5.1",
- "helm": "v3.17.0",
- "kubectl": "v1.32.1",
+ "helm": "v3.17.2",
+ "kubectl": "v1.32.2",
"mingit": "2.48.1.1"
}
diff --git a/go.mod b/go.mod
index 78f3a8adb..2c93a403a 100644
--- a/go.mod
+++ b/go.mod
@@ -48,11 +48,11 @@ require (
github.com/urfave/negroni v1.0.0
github.com/viney-shih/go-lock v1.1.1
go.etcd.io/bbolt v1.3.11
- golang.org/x/crypto v0.31.0
+ golang.org/x/crypto v0.35.0
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
golang.org/x/mod v0.21.0
golang.org/x/oauth2 v0.23.0
- golang.org/x/sync v0.10.0
+ golang.org/x/sync v0.11.0
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/yaml.v3 v3.0.1
helm.sh/helm/v3 v3.17.1
@@ -274,9 +274,9 @@ require (
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/net v0.33.0 // indirect
- golang.org/x/sys v0.28.0 // indirect
- golang.org/x/term v0.27.0 // indirect
- golang.org/x/text v0.21.0 // indirect
+ golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/term v0.29.0 // indirect
+ golang.org/x/text v0.22.0 // indirect
golang.org/x/time v0.7.0 // indirect
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
diff --git a/go.sum b/go.sum
index 278c4cdd3..8aab3b540 100644
--- a/go.sum
+++ b/go.sum
@@ -776,8 +776,8 @@ 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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
-golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
+golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -808,8 +808,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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
+golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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=
@@ -836,20 +836,20 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
-golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
-golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
+golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
From a67b917bddf3493c6810c3d947fc29cf24b47784 Mon Sep 17 00:00:00 2001
From: James Player
Date: Mon, 17 Mar 2025 16:00:33 +1300
Subject: [PATCH 049/212] Bump version to 2.28.0 (#523)
---
.github/ISSUE_TEMPLATE/bug_report.yml | 14 +++++++-------
api/datastore/test_data/output_24_to_latest.json | 4 ++--
api/http/handler/handler.go | 2 +-
api/portainer.go | 4 ++--
package.json | 2 +-
5 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 99d319aaf..4bd225980 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -2,18 +2,17 @@ name: Bug Report
description: Create a report to help us improve.
labels: kind/bug,bug/need-confirmation
body:
-
- type: markdown
attributes:
value: |
# Welcome!
-
+
The issue tracker is for reporting bugs. If you have an [idea for a new feature](https://github.com/orgs/portainer/discussions/categories/ideas) or a [general question about Portainer](https://github.com/orgs/portainer/discussions/categories/help) please post in our [GitHub Discussions](https://github.com/orgs/portainer/discussions).
-
+
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
Please note that we only provide support for current versions of Portainer. You can find a list of supported versions in our [lifecycle policy](https://docs.portainer.io/start/lifecycle).
-
+
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
- type: checkboxes
@@ -45,7 +44,7 @@ body:
- type: textarea
attributes:
label: Problem Description
- description: A clear and concise description of what the bug is.
+ description: A clear and concise description of what the bug is.
validations:
required: true
@@ -71,7 +70,7 @@ body:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
- 4. See error
+ 4. See error
validations:
required: true
@@ -95,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 [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
+ - '2.28.0'
- '2.27.1'
- '2.27.0'
- '2.26.1'
@@ -158,7 +158,7 @@ body:
- type: input
attributes:
label: Browser
- description: |
+ description: |
Enter your browser and version. Example: Google Chrome 114.0
validations:
required: false
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index 89d781169..dcbf37413 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -610,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
- "KubectlShellImage": "portainer/kubectl-shell:2.27.1",
+ "KubectlShellImage": "portainer/kubectl-shell:2.28.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
- "VERSION": "{\"SchemaVersion\":\"2.27.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
+ "VERSION": "{\"SchemaVersion\":\"2.28.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
\ No newline at end of file
diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go
index 7603e345b..d9ac7fbc6 100644
--- a/api/http/handler/handler.go
+++ b/api/http/handler/handler.go
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
-// @version 2.27.1
+// @version 2.28.0
// @description.markdown api-description.md
// @termsOfService
diff --git a/api/portainer.go b/api/portainer.go
index 60d09a993..b90e89b4e 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -1637,9 +1637,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
- APIVersion = "2.27.1"
+ APIVersion = "2.28.0"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
- APIVersionSupport = "LTS"
+ APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
diff --git a/package.json b/package.json
index 2068b5454..9e28a2441 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
- "version": "2.27.1",
+ "version": "2.28.0",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"
From 0296998faeb58a20284338cc7174a4ef31563397 Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Tue, 18 Mar 2025 17:55:53 -0300
Subject: [PATCH 050/212] fix(users): optimize the /users/me API endpoint
BE-11688 (#515)
Co-authored-by: andres-portainer
Co-authored-by: LP B
Co-authored-by: JamesPlayer
---
api/connection.go | 2 ++
api/database/boltdb/db.go | 26 +++++++++++++++++++
api/database/boltdb/tx.go | 28 +++++++++++++++++++++
api/dataservices/base.go | 14 +++++++++++
api/dataservices/base_tx.go | 6 +++++
api/http/handler/endpoints/endpoint_list.go | 23 +++++++----------
api/http/security/bouncer.go | 8 +++---
api/internal/testhelpers/datastore.go | 8 ++++++
8 files changed, 96 insertions(+), 19 deletions(-)
diff --git a/api/connection.go b/api/connection.go
index 710b978da..14e2dc1ca 100644
--- a/api/connection.go
+++ b/api/connection.go
@@ -6,8 +6,10 @@ import (
type ReadTransaction interface {
GetObject(bucketName string, key []byte, object any) error
+ GetRawBytes(bucketName string, key []byte) ([]byte, error)
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
+ KeyExists(bucketName string, key []byte) (bool, error)
}
type Transaction interface {
diff --git a/api/database/boltdb/db.go b/api/database/boltdb/db.go
index cef93b345..a0db7f4e0 100644
--- a/api/database/boltdb/db.go
+++ b/api/database/boltdb/db.go
@@ -244,6 +244,32 @@ func (connection *DbConnection) GetObject(bucketName string, key []byte, object
})
}
+func (connection *DbConnection) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
+ var value []byte
+
+ err := connection.ViewTx(func(tx portainer.Transaction) error {
+ var err error
+ value, err = tx.GetRawBytes(bucketName, key)
+
+ return err
+ })
+
+ return value, err
+}
+
+func (connection *DbConnection) KeyExists(bucketName string, key []byte) (bool, error) {
+ var exists bool
+
+ err := connection.ViewTx(func(tx portainer.Transaction) error {
+ var err error
+ exists, err = tx.KeyExists(bucketName, key)
+
+ return err
+ })
+
+ return exists, err
+}
+
func (connection *DbConnection) getEncryptionKey() []byte {
if !connection.isEncrypted {
return nil
diff --git a/api/database/boltdb/tx.go b/api/database/boltdb/tx.go
index 5de5d5333..2e45ac7b9 100644
--- a/api/database/boltdb/tx.go
+++ b/api/database/boltdb/tx.go
@@ -6,6 +6,7 @@ import (
dserrors "github.com/portainer/portainer/api/dataservices/errors"
+ "github.com/pkg/errors"
"github.com/rs/zerolog/log"
bolt "go.etcd.io/bbolt"
)
@@ -31,6 +32,33 @@ func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) er
return tx.conn.UnmarshalObject(value, object)
}
+func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
+ bucket := tx.tx.Bucket([]byte(bucketName))
+
+ value := bucket.Get(key)
+ if value == nil {
+ return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
+ }
+
+ if tx.conn.getEncryptionKey() != nil {
+ var err error
+
+ if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
+ return value, errors.Wrap(err, "Failed decrypting object")
+ }
+ }
+
+ return value, nil
+}
+
+func (tx *DbTransaction) KeyExists(bucketName string, key []byte) (bool, error) {
+ bucket := tx.tx.Bucket([]byte(bucketName))
+
+ value := bucket.Get(key)
+
+ return value != nil, nil
+}
+
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
data, err := tx.conn.MarshalObject(object)
if err != nil {
diff --git a/api/dataservices/base.go b/api/dataservices/base.go
index 9b1a42a53..04af70b02 100644
--- a/api/dataservices/base.go
+++ b/api/dataservices/base.go
@@ -9,6 +9,7 @@ import (
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)
Update(ID I, element *T) error
Delete(ID I) error
@@ -42,6 +43,19 @@ func (service BaseDataService[T, I]) Read(ID I) (*T, error) {
})
}
+func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
+ var exists bool
+
+ err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
+ var err error
+ exists, err = service.Tx(tx).Exists(ID)
+
+ return err
+ })
+
+ return exists, err
+}
+
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
var collection = make([]T, 0)
diff --git a/api/dataservices/base_tx.go b/api/dataservices/base_tx.go
index db1e702cb..d9915b64c 100644
--- a/api/dataservices/base_tx.go
+++ b/api/dataservices/base_tx.go
@@ -28,6 +28,12 @@ func (service BaseDataServiceTx[T, I]) Read(ID I) (*T, error) {
return &element, nil
}
+func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
+ identifier := service.Connection.ConvertToKey(int(ID))
+
+ return service.Tx.KeyExists(service.Bucket, identifier)
+}
+
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
var collection = make([]T, 0)
diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go
index dc95e15b0..7ea557910 100644
--- a/api/http/handler/endpoints/endpoint_list.go
+++ b/api/http/handler/endpoints/endpoint_list.go
@@ -105,14 +105,16 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
for idx := range paginatedEndpoints {
hideFields(&paginatedEndpoints[idx])
+
paginatedEndpoints[idx].ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
if paginatedEndpoints[idx].EdgeCheckinInterval == 0 {
paginatedEndpoints[idx].EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
}
+
endpointutils.UpdateEdgeEndpointHeartbeat(&paginatedEndpoints[idx], settings)
+
if !query.excludeSnapshots {
- err = handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx])
- if err != nil {
+ if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx]); err != nil {
return httperror.InternalServerError("Unable to add snapshot data", err)
}
}
@@ -120,6 +122,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
w.Header().Set("X-Total-Count", strconv.Itoa(filteredEndpointCount))
w.Header().Set("X-Total-Available", strconv.Itoa(totalAvailableEndpoints))
+
return response.JSON(w, paginatedEndpoints)
}
@@ -130,18 +133,8 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
endpointCount := len(endpoints)
- if start < 0 {
- start = 0
- }
-
- if start > endpointCount {
- start = endpointCount
- }
-
- end := start + limit
- if end > endpointCount {
- end = endpointCount
- }
+ start = min(max(start, 0), endpointCount)
+ end := min(start+limit, endpointCount)
return endpoints[start:end]
}
@@ -151,8 +144,10 @@ func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.Endp
for _, group := range groups {
if group.ID == groupID {
endpointGroup = group
+
break
}
}
+
return endpointGroup
}
diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go
index 00f69e328..eb240692d 100644
--- a/api/http/security/bouncer.go
+++ b/api/http/security/bouncer.go
@@ -243,8 +243,7 @@ func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler,
return
}
- _, err = bouncer.dataStore.User().Read(tokenData.ID)
- if bouncer.dataStore.IsErrObjectNotFound(err) {
+ if ok, err := bouncer.dataStore.User().Exists(tokenData.ID); !ok {
httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized)
return
} else if err != nil {
@@ -322,9 +321,8 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
return
}
- user, _ := bouncer.dataStore.User().Read(token.ID)
- if user == nil {
- httperror.WriteError(w, http.StatusUnauthorized, "An authorization token is invalid", httperrors.ErrUnauthorized)
+ if ok, _ := bouncer.dataStore.User().Exists(token.ID); !ok {
+ httperror.WriteError(w, http.StatusUnauthorized, "The authorization token is invalid", httperrors.ErrUnauthorized)
return
}
diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go
index 19d4df762..9a16ddfd2 100644
--- a/api/internal/testhelpers/datastore.go
+++ b/api/internal/testhelpers/datastore.go
@@ -153,6 +153,7 @@ func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User
func (s *stubUserService) Create(user *portainer.User) error { return nil }
func (s *stubUserService) Update(ID portainer.UserID, user *portainer.User) error { return nil }
func (s *stubUserService) Delete(ID portainer.UserID) error { return nil }
+func (s *stubUserService) Exists(ID portainer.UserID) (bool, error) { return false, nil }
// WithUsers testDatastore option that will instruct testDatastore to return provided users
func WithUsers(us []portainer.User) datastoreOption {
@@ -188,6 +189,9 @@ func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFun
}
func (s *stubEdgeJobService) Delete(ID portainer.EdgeJobID) error { return nil }
func (s *stubEdgeJobService) GetNextIdentifier() int { return 0 }
+func (s *stubEdgeJobService) Exists(ID portainer.EdgeJobID) (bool, error) {
+ return false, nil
+}
// WithEdgeJobs option will instruct testDatastore to return provided jobs
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
@@ -452,6 +456,10 @@ func (s *stubStacksService) GetNextIdentifier() int {
return len(s.stacks)
}
+func (s *stubStacksService) Exists(ID portainer.StackID) (bool, error) {
+ return false, nil
+}
+
// WithStacks option will instruct testDatastore to return provided stacks
func WithStacks(stacks []portainer.Stack) datastoreOption {
return func(d *testDatastore) {
From c01f0271fef8e6845d413b2bcffe755d7c464cb1 Mon Sep 17 00:00:00 2001
From: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Date: Wed, 19 Mar 2025 17:41:36 +1300
Subject: [PATCH 051/212] Update bug report template for 2.27.2 (#539)
---
.github/ISSUE_TEMPLATE/bug_report.yml | 13 ++-----------
1 file changed, 2 insertions(+), 11 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 4bd225980..dc640387c 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -91,10 +91,11 @@ body:
- type: dropdown
attributes:
label: Portainer version
- 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 [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
+ description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.28.0'
+ - '2.27.2'
- '2.27.1'
- '2.27.0'
- '2.26.1'
@@ -111,16 +112,6 @@ body:
- '2.21.2'
- '2.21.1'
- '2.21.0'
- - '2.20.3'
- - '2.20.2'
- - '2.20.1'
- - '2.20.0'
- - '2.19.5'
- - '2.19.4'
- - '2.19.3'
- - '2.19.2'
- - '2.19.1'
- - '2.19.0'
validations:
required: true
From 38562f9560b0202bc0f3d74c62caad9f77b884a6 Mon Sep 17 00:00:00 2001
From: Viktor Pettersson
Date: Wed, 19 Mar 2025 13:08:03 +0100
Subject: [PATCH 052/212] fix(api): remove duplicated /users/me route
[BE-11689] (#516)
---
api/http/handler/users/handler.go | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go
index c0abba8c4..54be6a24e 100644
--- a/api/http/handler/users/handler.go
+++ b/api/http/handler/users/handler.go
@@ -52,12 +52,12 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
teamLeaderRouter := h.NewRoute().Subrouter()
teamLeaderRouter.Use(bouncer.TeamLeaderAccess)
- restrictedRouter := h.NewRoute().Subrouter()
- restrictedRouter.Use(bouncer.RestrictedAccess)
-
authenticatedRouter := h.NewRoute().Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
+ restrictedRouter := h.NewRoute().Subrouter()
+ restrictedRouter.Use(bouncer.RestrictedAccess)
+
publicRouter := h.NewRoute().Subrouter()
publicRouter.Use(bouncer.PublicAccess)
@@ -65,7 +65,6 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
restrictedRouter.Handle("/users", httperror.LoggerHandler(h.userList)).Methods(http.MethodGet)
authenticatedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet)
- restrictedRouter.Handle("/users/me", httperror.LoggerHandler(h.userInspectMe)).Methods(http.MethodGet)
restrictedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userInspect)).Methods(http.MethodGet)
authenticatedRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/users/{id}", httperror.LoggerHandler(h.userDelete)).Methods(http.MethodDelete)
From 4b992c6f3e33a50438dbdafbf04a07ce3b376d1a Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Thu, 20 Mar 2025 08:49:27 +1300
Subject: [PATCH 053/212] fix(k8s/config): force insecure-skip-tls-verify
option for internal use [BE-11706] (#537)
---
api/http/handler/kubernetes/config.go | 10 ++++++++++
api/kubernetes/kubeclusteraccess_service.go | 1 +
2 files changed, 11 insertions(+)
diff --git a/api/http/handler/kubernetes/config.go b/api/http/handler/kubernetes/config.go
index a42f98ee3..b9de1824b 100644
--- a/api/http/handler/kubernetes/config.go
+++ b/api/http/handler/kubernetes/config.go
@@ -167,6 +167,16 @@ func (handler *Handler) buildConfig(r *http.Request, tokenData *portainer.TokenD
func (handler *Handler) buildCluster(r *http.Request, endpoint portainer.Endpoint, isInternal bool) clientV1.NamedCluster {
kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(r.Host, endpoint.ID, isInternal)
+ if isInternal {
+ return clientV1.NamedCluster{
+ Name: buildClusterName(endpoint.Name),
+ Cluster: clientV1.Cluster{
+ Server: kubeConfigInternal.ClusterServerURL,
+ InsecureSkipTLSVerify: true,
+ },
+ }
+ }
+
selfSignedCert := false
serverUrl, err := url.Parse(kubeConfigInternal.ClusterServerURL)
if err != nil {
diff --git a/api/kubernetes/kubeclusteraccess_service.go b/api/kubernetes/kubeclusteraccess_service.go
index 60f0cc503..fce7f55ea 100644
--- a/api/kubernetes/kubeclusteraccess_service.go
+++ b/api/kubernetes/kubeclusteraccess_service.go
@@ -109,6 +109,7 @@ func (service *kubeClusterAccessService) GetClusterDetails(hostURL string, endpo
Str("host_URL", hostURL).
Str("HTTPS_bind_address", service.httpsBindAddr).
Str("base_URL", baseURL).
+ Bool("is_internal", isInternal).
Msg("kubeconfig")
clusterServerURL, err := url.JoinPath("https://", hostURL, baseURL, "/api/endpoints/", strconv.Itoa(int(endpointID)), "/kubernetes")
From 5d1b42b3144aa5d659eefdcc72fbc482f534c8ba Mon Sep 17 00:00:00 2001
From: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Date: Thu, 20 Mar 2025 15:54:53 +1300
Subject: [PATCH 054/212] Update bug report template for 2.28.1 (#549)
---
.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 dc640387c..ef5a073fb 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.28.1'
- '2.28.0'
- '2.27.2'
- '2.27.1'
From a61c1004d31be34665470e6aebbbc63f9b619551 Mon Sep 17 00:00:00 2001
From: Viktor Pettersson
Date: Thu, 20 Mar 2025 10:05:15 +0100
Subject: [PATCH 055/212] fix(agent-updates): fix remote agent updates cannot
be scheduled properly for large edge groups [BE-11691] (#528)
---
.../environments/environment.service/index.ts | 2 ++
app/react/portainer/environments/types.ts | 2 ++
.../common/RollbackOptions.tsx | 24 +++++--------
.../common/ScheduleTypeSelector.tsx | 6 +---
.../common/useEdgeGroupsEnvironmentIds.ts | 34 ------------------
.../common/useEnvironments.ts | 8 ++---
.../update-schedules/queries/query-keys.ts | 8 +++--
.../queries/usePreviousVersions.ts | 36 +++++++++++--------
8 files changed, 46 insertions(+), 74 deletions(-)
delete mode 100644 app/react/portainer/environments/update-schedules/common/useEdgeGroupsEnvironmentIds.ts
diff --git a/app/react/portainer/environments/environment.service/index.ts b/app/react/portainer/environments/environment.service/index.ts
index 8971a4af0..f96d57d69 100644
--- a/app/react/portainer/environments/environment.service/index.ts
+++ b/app/react/portainer/environments/environment.service/index.ts
@@ -7,6 +7,7 @@ import {
EnvironmentStatus,
EnvironmentGroupId,
PlatformType,
+ EdgeGroupId,
} from '@/react/portainer/environments/types';
import { type TagId } from '@/portainer/tags/types';
import { UserId } from '@/portainer/users/types';
@@ -47,6 +48,7 @@ export interface BaseEnvironmentsQueryParams {
updateInformation?: boolean;
edgeCheckInPassedSeconds?: number;
platformTypes?: PlatformType[];
+ edgeGroupIds?: EdgeGroupId[];
}
export type EnvironmentsQueryParams = BaseEnvironmentsQueryParams &
diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts
index e9dc5aa25..9279e0d4c 100644
--- a/app/react/portainer/environments/types.ts
+++ b/app/react/portainer/environments/types.ts
@@ -3,6 +3,8 @@ import { DockerSnapshot } from '@/react/docker/snapshots/types';
export type EnvironmentGroupId = number;
+export type EdgeGroupId = number;
+
type RoleId = number;
interface AccessPolicy {
RoleId: RoleId;
diff --git a/app/react/portainer/environments/update-schedules/common/RollbackOptions.tsx b/app/react/portainer/environments/update-schedules/common/RollbackOptions.tsx
index 4b4265a65..be354152c 100644
--- a/app/react/portainer/environments/update-schedules/common/RollbackOptions.tsx
+++ b/app/react/portainer/environments/update-schedules/common/RollbackOptions.tsx
@@ -9,7 +9,6 @@ import { TextTip } from '@@/Tip/TextTip';
import { usePreviousVersions } from '../queries/usePreviousVersions';
import { FormValues } from './types';
-import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
export function RollbackOptions() {
const { isLoading, count, version, versionError } = useSelectVersionOnMount();
@@ -50,24 +49,19 @@ function useSelectVersionOnMount() {
errors: { version: versionError },
} = useFormikContext();
- const environmentIdsQuery = useEdgeGroupsEnvironmentIds(groupIds);
-
- const previousVersionsQuery = usePreviousVersions({
- enabled: !!environmentIdsQuery.data,
- });
+ const previousVersionsQuery = usePreviousVersions(groupIds ?? []);
const previousVersions = useMemo(
() =>
previousVersionsQuery.data
- ? _.uniq(
- _.compact(
- environmentIdsQuery.data?.map(
- (envId) => previousVersionsQuery.data[envId]
- )
- )
- )
+ ? _.uniq(Object.values(previousVersionsQuery.data))
: [],
- [environmentIdsQuery.data, previousVersionsQuery.data]
+ [previousVersionsQuery.data]
+ );
+
+ const previousVersionCount = useMemo(
+ () => Object.keys(previousVersions).length,
+ [previousVersions]
);
useEffect(() => {
@@ -91,7 +85,7 @@ function useSelectVersionOnMount() {
isLoading: previousVersionsQuery.isLoading,
versionError,
version,
- count: environmentIdsQuery.data?.length,
+ count: previousVersionCount,
};
}
diff --git a/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx b/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx
index 212ff7c00..8d088f7d2 100644
--- a/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx
+++ b/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx
@@ -7,7 +7,6 @@ import { NavContainer } from '@@/NavTabs/NavContainer';
import { ScheduleType } from '../types';
-import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
import { useEnvironments } from './useEnvironments';
import { defaultValue } from './ScheduledTimeField';
import { FormValues } from './types';
@@ -17,10 +16,7 @@ import { RollbackScheduleDetailsFieldset } from './RollbackScheduleDetailsFields
export function ScheduleTypeSelector() {
const { values, setFieldValue } = useFormikContext();
- const environmentIdsQuery = useEdgeGroupsEnvironmentIds(values.groupIds);
-
- const edgeGroupsEnvironmentIds = environmentIdsQuery.data || [];
- const environments = useEnvironments(edgeGroupsEnvironmentIds);
+ const environments = useEnvironments(values.groupIds);
// old version is version that doesn't support scheduling of updates
const hasNoTimeZone = environments.some((env) => !env.LocalTimeZone);
diff --git a/app/react/portainer/environments/update-schedules/common/useEdgeGroupsEnvironmentIds.ts b/app/react/portainer/environments/update-schedules/common/useEdgeGroupsEnvironmentIds.ts
deleted file mode 100644
index 6f44c7dab..000000000
--- a/app/react/portainer/environments/update-schedules/common/useEdgeGroupsEnvironmentIds.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import _ from 'lodash';
-import { useMemo } from 'react';
-
-import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
-import { EdgeGroup } from '@/react/edge/edge-groups/types';
-
-export function useEdgeGroupsEnvironmentIds(
- edgeGroupsIds: Array
-) {
- const groupsQuery = useEdgeGroups({
- select: (groups) =>
- Object.fromEntries(groups.map((g) => [g.Id, g.Endpoints])),
- });
-
- const envIds = useMemo(
- () =>
- _.uniq(
- _.compact(
- edgeGroupsIds.flatMap((id) =>
- groupsQuery.data ? groupsQuery.data[id] : []
- )
- )
- ),
- [edgeGroupsIds, groupsQuery.data]
- );
-
- return useMemo(
- () => ({
- data: groupsQuery.data ? envIds : null,
- isLoading: groupsQuery.isLoading,
- }),
- [envIds, groupsQuery.data, groupsQuery.isLoading]
- );
-}
diff --git a/app/react/portainer/environments/update-schedules/common/useEnvironments.ts b/app/react/portainer/environments/update-schedules/common/useEnvironments.ts
index 94ef89d86..bfe9a83b4 100644
--- a/app/react/portainer/environments/update-schedules/common/useEnvironments.ts
+++ b/app/react/portainer/environments/update-schedules/common/useEnvironments.ts
@@ -1,11 +1,11 @@
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
-import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
+import { EdgeGroupId, EdgeTypes } from '@/react/portainer/environments/types';
-export function useEnvironments(environmentsIds: Array) {
+export function useEnvironments(edgeGroupIds: Array) {
const environmentsQuery = useEnvironmentList(
- { endpointIds: environmentsIds, types: EdgeTypes, pageLimit: 0 },
+ { edgeGroupIds, types: EdgeTypes, pageLimit: 0 },
{
- enabled: environmentsIds.length > 0,
+ enabled: edgeGroupIds.length > 0,
}
);
diff --git a/app/react/portainer/environments/update-schedules/queries/query-keys.ts b/app/react/portainer/environments/update-schedules/queries/query-keys.ts
index ebb0b5112..f6e51532e 100644
--- a/app/react/portainer/environments/update-schedules/queries/query-keys.ts
+++ b/app/react/portainer/environments/update-schedules/queries/query-keys.ts
@@ -1,4 +1,7 @@
-import { EnvironmentId } from '@/react/portainer/environments/types';
+import {
+ EdgeGroupId,
+ EnvironmentId,
+} from '@/react/portainer/environments/types';
import { EdgeUpdateSchedule } from '../types';
@@ -11,5 +14,6 @@ export const queryKeys = {
[...queryKeys.base(), 'active', { environmentIds }] as const,
supportedAgentVersions: () =>
[...queryKeys.base(), 'agent_versions'] as const,
- previousVersions: () => [...queryKeys.base(), 'previous_versions'] as const,
+ previousVersions: (edgeGroupIds?: EdgeGroupId[]) =>
+ [...queryKeys.base(), 'previous_versions', { edgeGroupIds }] as const,
};
diff --git a/app/react/portainer/environments/update-schedules/queries/usePreviousVersions.ts b/app/react/portainer/environments/update-schedules/queries/usePreviousVersions.ts
index be055ffdc..3f2905b2a 100644
--- a/app/react/portainer/environments/update-schedules/queries/usePreviousVersions.ts
+++ b/app/react/portainer/environments/update-schedules/queries/usePreviousVersions.ts
@@ -1,7 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
-import { EnvironmentId } from '@/react/portainer/environments/types';
+import {
+ EdgeGroupId,
+ EnvironmentId,
+} from '@/react/portainer/environments/types';
import { queryKeys } from './query-keys';
import { buildUrl } from './urls';
@@ -12,22 +15,27 @@ interface Options {
enabled?: boolean;
}
-export function usePreviousVersions>({
- select,
- onSuccess,
- enabled,
-}: Options = {}) {
- return useQuery(queryKeys.previousVersions(), getPreviousVersions, {
- select,
- onSuccess,
- enabled,
- });
+export function usePreviousVersions>(
+ edgeGroupIds: EdgeGroupId[],
+ { select, enabled }: Options = {}
+) {
+ return useQuery(
+ queryKeys.previousVersions(edgeGroupIds),
+ () => getPreviousVersions(edgeGroupIds),
+ {
+ select,
+ enabled: enabled && edgeGroupIds.length > 0,
+ }
+ );
}
-async function getPreviousVersions() {
+async function getPreviousVersions(edgeGroupIds: EdgeGroupId[]) {
try {
- const { data } = await axios.get>(
- buildUrl(undefined, 'previous_versions')
+ const { data } = await axios.get>(
+ buildUrl(undefined, 'previous_versions'),
+ {
+ params: { edgeGroupIds },
+ }
);
return data;
} catch (err) {
From 4b218553c35aa52e94183f4df08b417da949656f Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Fri, 21 Mar 2025 09:19:25 +1300
Subject: [PATCH 056/212] fix(libstack): data loss for stack with relative path
[FR-437] (#548)
---
api/filesystem/copy.go | 2 +-
pkg/libstack/compose/composeplugin.go | 5 +++++
2 files changed, 6 insertions(+), 1 deletion(-)
diff --git a/api/filesystem/copy.go b/api/filesystem/copy.go
index abf4d33aa..bc0abb766 100644
--- a/api/filesystem/copy.go
+++ b/api/filesystem/copy.go
@@ -68,7 +68,7 @@ func copyFile(src, dst string) error {
defer from.Close()
// has to include 'execute' bit, otherwise fails. MkdirAll follows `mkdir -m` restrictions
- if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil {
+ if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
to, err := os.Create(dst)
diff --git a/pkg/libstack/compose/composeplugin.go b/pkg/libstack/compose/composeplugin.go
index c83685351..8ebc12062 100644
--- a/pkg/libstack/compose/composeplugin.go
+++ b/pkg/libstack/compose/composeplugin.go
@@ -98,6 +98,11 @@ func withComposeService(
WorkingDir: filepath.Dir(filePaths[0]),
}
+ if options.ProjectDir != "" {
+ // When relative paths are used in the compose file, the project directory is used as the base path
+ configDetails.WorkingDir = options.ProjectDir
+ }
+
for _, p := range filePaths {
configDetails.ConfigFiles = append(configDetails.ConfigFiles, types.ConfigFile{Filename: p})
}
From 1d8ea7b0eeb5de82a742c6c20aa2cbedc4be9f4b Mon Sep 17 00:00:00 2001
From: Anthony Lapenna
Date: Fri, 21 Mar 2025 16:45:43 +1300
Subject: [PATCH 057/212] docs: review TeamUpdate API operation (#564)
---
api/http/handler/teams/team_update.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/api/http/handler/teams/team_update.go b/api/http/handler/teams/team_update.go
index 3ad4a3151..5731f294c 100644
--- a/api/http/handler/teams/team_update.go
+++ b/api/http/handler/teams/team_update.go
@@ -30,7 +30,6 @@ func (payload *teamUpdatePayload) Validate(r *http.Request) error {
// @param id path int true "Team identifier"
// @param body body teamUpdatePayload true "Team details"
// @success 200 {object} portainer.Team "Success"
-// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Team not found"
From 5d1cd670e93d7cf95bfbd097ee37d6778125427d Mon Sep 17 00:00:00 2001
From: Anthony Lapenna
Date: Mon, 24 Mar 2025 09:55:33 +1300
Subject: [PATCH 058/212] docs: review TeamMembershipCreate API operation
(#565)
---
api/http/handler/teammemberships/teammembership_create.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/api/http/handler/teammemberships/teammembership_create.go b/api/http/handler/teammemberships/teammembership_create.go
index f94d58395..d9acb9b44 100644
--- a/api/http/handler/teammemberships/teammembership_create.go
+++ b/api/http/handler/teammemberships/teammembership_create.go
@@ -45,7 +45,6 @@ func (payload *teamMembershipCreatePayload) Validate(r *http.Request) error {
// @produce json
// @param body body teamMembershipCreatePayload true "Team membership details"
// @success 200 {object} portainer.TeamMembership "Success"
-// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to manage memberships"
// @failure 409 "Team membership already registered"
From 34235199dd8950e68bf4739f30863363a799fb16 Mon Sep 17 00:00:00 2001
From: Devon Steenberg
Date: Tue, 25 Mar 2025 08:57:23 +1300
Subject: [PATCH 059/212] fix(libstack): correctly load COMPOSE_* env vars
[BE-11474] (#536)
---
go.mod | 1 +
go.sum | 2 +
pkg/libstack/compose/compose.go | 14 +-
pkg/libstack/compose/composeplugin.go | 170 ++--
pkg/libstack/compose/composeplugin_test.go | 741 ++++++++++++++++++
.../compose/composeplugin_windows_test.go | 109 +++
pkg/libstack/compose/status.go | 2 +-
7 files changed, 952 insertions(+), 87 deletions(-)
create mode 100644 pkg/libstack/compose/composeplugin_windows_test.go
diff --git a/go.mod b/go.mod
index 2c93a403a..4a28e80a1 100644
--- a/go.mod
+++ b/go.mod
@@ -192,6 +192,7 @@ require (
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
+ github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
diff --git a/go.sum b/go.sum
index 8aab3b540..8fbb68613 100644
--- a/go.sum
+++ b/go.sum
@@ -481,6 +481,8 @@ github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
+github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
+github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
diff --git a/pkg/libstack/compose/compose.go b/pkg/libstack/compose/compose.go
index 3cadae4ba..6e859a93f 100644
--- a/pkg/libstack/compose/compose.go
+++ b/pkg/libstack/compose/compose.go
@@ -1,8 +1,18 @@
package compose
-type ComposeDeployer struct{}
+import (
+ "github.com/docker/cli/cli/command"
+ "github.com/docker/compose/v2/pkg/api"
+ "github.com/docker/compose/v2/pkg/compose"
+)
+
+type ComposeDeployer struct {
+ createComposeServiceFn func(command.Cli) api.Service
+}
// NewComposeDeployer creates a new compose deployer
func NewComposeDeployer() *ComposeDeployer {
- return &ComposeDeployer{}
+ return &ComposeDeployer{
+ createComposeServiceFn: compose.NewComposeService,
+ }
}
diff --git a/pkg/libstack/compose/composeplugin.go b/pkg/libstack/compose/composeplugin.go
index 8ebc12062..17126cc21 100644
--- a/pkg/libstack/compose/composeplugin.go
+++ b/pkg/libstack/compose/composeplugin.go
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"maps"
- "os"
"path/filepath"
"slices"
"strconv"
@@ -15,13 +14,14 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/libstack"
- "github.com/compose-spec/compose-go/v2/dotenv"
- "github.com/compose-spec/compose-go/v2/loader"
+ "github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/flags"
+ cmdcompose "github.com/docker/compose/v2/cmd/compose"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose"
+ "github.com/docker/compose/v2/pkg/utils"
"github.com/docker/docker/registry"
"github.com/rs/zerolog/log"
"github.com/sirupsen/logrus"
@@ -75,66 +75,34 @@ func withCli(
return cliFn(ctx, cli)
}
-func withComposeService(
+func (c *ComposeDeployer) withComposeService(
ctx context.Context,
filePaths []string,
options libstack.Options,
composeFn func(api.Service, *types.Project) error,
) error {
return withCli(ctx, options, func(ctx context.Context, cli *command.DockerCli) error {
- composeService := compose.NewComposeService(cli)
+ composeService := c.createComposeServiceFn(cli)
if len(filePaths) == 0 {
return composeFn(composeService, nil)
}
- env, err := parseEnvironment(options, filePaths)
+ project, err := createProject(ctx, filePaths, options)
if err != nil {
- return err
+ return fmt.Errorf("failed to create compose project: %w", err)
}
- configDetails := types.ConfigDetails{
- Environment: env,
- WorkingDir: filepath.Dir(filePaths[0]),
- }
-
- if options.ProjectDir != "" {
- // When relative paths are used in the compose file, the project directory is used as the base path
- configDetails.WorkingDir = options.ProjectDir
- }
-
- for _, p := range filePaths {
- configDetails.ConfigFiles = append(configDetails.ConfigFiles, types.ConfigFile{Filename: p})
- }
-
- project, err := loader.LoadWithContext(ctx, configDetails,
- func(o *loader.Options) {
- o.SkipResolveEnvironment = true
- o.ResolvePaths = !slices.Contains(options.ConfigOptions, "--no-path-resolution")
-
- if options.ProjectName != "" {
- o.SetProjectName(options.ProjectName, true)
- }
- },
- )
- if err != nil {
- return fmt.Errorf("failed to load the compose file: %w", err)
- }
-
- // Work around compose path handling
- for i, service := range project.Services {
- for j, envFile := range service.EnvFiles {
- if !filepath.IsAbs(envFile.Path) {
- project.Services[i].EnvFiles[j].Path = filepath.Join(configDetails.WorkingDir, envFile.Path)
- }
+ parallel := 0
+ if v, ok := project.Environment[cmdcompose.ComposeParallelLimit]; ok {
+ i, err := strconv.Atoi(v)
+ if err != nil {
+ return fmt.Errorf("%s must be an integer (found: %q)", cmdcompose.ComposeParallelLimit, v)
}
+ parallel = i
}
-
- // Set the services environment variables
- if p, err := project.WithServicesEnvironmentResolved(true); err == nil {
- project = p
- } else {
- return fmt.Errorf("failed to resolve services environment: %w", err)
+ if parallel > 0 {
+ composeService.MaxConcurrency(parallel)
}
return composeFn(composeService, project)
@@ -143,7 +111,7 @@ func withComposeService(
// Deploy creates and starts containers
func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, options libstack.DeployOptions) error {
- return withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
+ return c.withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
addServiceLabels(project, false, options.EdgeStackID)
project = project.WithoutUnnecessaryResources()
@@ -154,6 +122,12 @@ func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, option
}
opts.Create.RemoveOrphans = options.RemoveOrphans
+ if removeOrphans, ok := project.Environment[cmdcompose.ComposeRemoveOrphans]; ok {
+ opts.Create.RemoveOrphans = utils.StringToBool(removeOrphans)
+ }
+ if ignoreOrphans, ok := project.Environment[cmdcompose.ComposeIgnoreOrphans]; ok {
+ opts.Create.IgnoreOrphans = utils.StringToBool(ignoreOrphans)
+ }
if options.AbortOnContainerExit {
opts.Start.OnExit = api.CascadeStop
@@ -175,7 +149,7 @@ func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, option
// Run runs the given service just once, without considering dependencies
func (c *ComposeDeployer) Run(ctx context.Context, filePaths []string, serviceName string, options libstack.RunOptions) error {
- return withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
+ return c.withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
addServiceLabels(project, true, 0)
for name, service := range project.Services {
@@ -227,7 +201,7 @@ func (c *ComposeDeployer) Remove(ctx context.Context, projectName string, filePa
// Pull pulls images
func (c *ComposeDeployer) Pull(ctx context.Context, filePaths []string, options libstack.Options) error {
- if err := withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
+ if err := c.withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
return composeService.Pull(ctx, project, api.PullOptions{})
}); err != nil {
return fmt.Errorf("compose pull operation failed: %w", err)
@@ -240,7 +214,7 @@ func (c *ComposeDeployer) Pull(ctx context.Context, filePaths []string, options
// Validate validates stack file
func (c *ComposeDeployer) Validate(ctx context.Context, filePaths []string, options libstack.Options) error {
- return withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
+ return c.withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
return nil
})
}
@@ -249,7 +223,7 @@ func (c *ComposeDeployer) Validate(ctx context.Context, filePaths []string, opti
func (c *ComposeDeployer) Config(ctx context.Context, filePaths []string, options libstack.Options) ([]byte, error) {
var payload []byte
- if err := withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
+ if err := c.withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
var err error
payload, err = project.MarshalYAML()
if err != nil {
@@ -267,7 +241,7 @@ func (c *ComposeDeployer) Config(ctx context.Context, filePaths []string, option
func (c *ComposeDeployer) GetExistingEdgeStacks(ctx context.Context) ([]libstack.EdgeStack, error) {
m := make(map[int]libstack.EdgeStack)
- if err := withComposeService(ctx, nil, libstack.Options{}, func(composeService api.Service, project *types.Project) error {
+ if err := c.withComposeService(ctx, nil, libstack.Options{}, func(composeService api.Service, project *types.Project) error {
stacks, err := composeService.List(ctx, api.ListOptions{
All: true,
})
@@ -332,43 +306,71 @@ func addServiceLabels(project *types.Project, oneOff bool, edgeStackID portainer
}
}
-func parseEnvironment(options libstack.Options, filePaths []string) (map[string]string, error) {
- env := make(map[string]string)
-
- for _, envLine := range options.Env {
- e, err := dotenv.UnmarshalWithLookup(envLine, nil)
- if err != nil {
- return nil, fmt.Errorf("unable to parse environment variables: %w", err)
- }
-
- maps.Copy(env, e)
+func createProject(ctx context.Context, configFilepaths []string, options libstack.Options) (*types.Project, error) {
+ var workingDir string
+ if len(configFilepaths) > 0 {
+ workingDir = filepath.Dir(configFilepaths[0])
}
- if options.EnvFilePath == "" {
- if len(filePaths) == 0 {
- return env, nil
- }
-
- defaultDotEnv := filepath.Join(filepath.Dir(filePaths[0]), ".env")
- s, err := os.Stat(defaultDotEnv)
- if os.IsNotExist(err) {
- return env, nil
- }
- if err != nil {
- return env, err
- }
- if s.IsDir() {
- return env, nil
- }
- options.EnvFilePath = defaultDotEnv
+ if options.WorkingDir != "" {
+ workingDir = options.WorkingDir
}
- e, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath})
+ if options.ProjectDir != "" {
+ // When relative paths are used in the compose file, the project directory is used as the base path
+ workingDir = options.ProjectDir
+ }
+
+ var envFiles []string
+ if options.EnvFilePath != "" {
+ envFiles = append(envFiles, options.EnvFilePath)
+ }
+
+ projectOptions, err := cli.NewProjectOptions(configFilepaths,
+ cli.WithWorkingDirectory(workingDir),
+ cli.WithName(options.ProjectName),
+ cli.WithoutEnvironmentResolution,
+ cli.WithResolvedPaths(!slices.Contains(options.ConfigOptions, "--no-path-resolution")),
+ cli.WithEnv(options.Env),
+ cli.WithEnvFiles(envFiles...),
+ func(o *cli.ProjectOptions) error {
+ if len(o.EnvFiles) > 0 {
+ return nil
+ }
+
+ if fs, ok := o.Environment[cmdcompose.ComposeEnvFiles]; ok {
+ o.EnvFiles = strings.Split(fs, ",")
+ }
+ return nil
+ },
+ cli.WithDotEnv,
+ cli.WithDefaultProfiles(),
+ cli.WithConfigFileEnv,
+ )
if err != nil {
- return nil, fmt.Errorf("unable to get the environment from the env file: %w", err)
+ return nil, fmt.Errorf("failed to load the compose file options : %w", err)
}
- maps.Copy(env, e)
+ project, err := projectOptions.LoadProject(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load the compose file : %w", err)
+ }
- return env, nil
+ // Work around compose path handling
+ for i, service := range project.Services {
+ for j, envFile := range service.EnvFiles {
+ if !filepath.IsAbs(envFile.Path) {
+ project.Services[i].EnvFiles[j].Path = filepath.Join(workingDir, envFile.Path)
+ }
+ }
+ }
+
+ // Set the services environment variables
+ if p, err := project.WithServicesEnvironmentResolved(true); err == nil {
+ project = p
+ } else {
+ return nil, fmt.Errorf("failed to resolve services environment: %w", err)
+ }
+
+ return project, nil
}
diff --git a/pkg/libstack/compose/composeplugin_test.go b/pkg/libstack/compose/composeplugin_test.go
index dd423b700..df71c5d36 100644
--- a/pkg/libstack/compose/composeplugin_test.go
+++ b/pkg/libstack/compose/composeplugin_test.go
@@ -2,14 +2,23 @@ package compose
import (
"context"
+ "errors"
"log"
"os"
"os/exec"
"path/filepath"
+ "strconv"
"strings"
"testing"
+ "github.com/compose-spec/compose-go/v2/consts"
+ "github.com/compose-spec/compose-go/v2/types"
+ "github.com/docker/cli/cli/command"
+ cmdcompose "github.com/docker/compose/v2/cmd/compose"
+ "github.com/docker/compose/v2/pkg/api"
+ "github.com/google/go-cmp/cmp"
"github.com/portainer/portainer/pkg/libstack"
+ zerolog "github.com/rs/zerolog/log"
"github.com/stretchr/testify/require"
)
@@ -349,3 +358,735 @@ networks:
})
}
}
+
+func Test_DeployWithRemoveOrphans(t *testing.T) {
+ const projectName = "compose_remove_orphans_test"
+
+ const composeFileContent = `services:
+ service-1:
+ image: alpine:latest
+ service-2:
+ image: alpine:latest`
+
+ const modifiedFileContent = `services:
+ service-2:
+ image: alpine:latest`
+
+ service1ContainerName := projectName + "-service-1"
+ service2ContainerName := projectName + "-service-2"
+
+ w := NewComposeDeployer()
+
+ dir := t.TempDir()
+
+ composeFilepath := createFile(t, dir, "docker-compose.yml", composeFileContent)
+ modifiedComposeFilepath := createFile(t, dir, "docker-compose-modified.yml", modifiedFileContent)
+
+ filepaths := []string{composeFilepath}
+ modifiedFilepaths := []string{modifiedComposeFilepath}
+
+ ctx := context.Background()
+
+ testCases := []struct {
+ name string
+ options libstack.DeployOptions
+ }{
+ {
+ name: "Remove Orphans in env",
+ options: libstack.DeployOptions{
+ Options: libstack.Options{
+ ProjectName: projectName,
+ Env: []string{cmdcompose.ComposeRemoveOrphans + "=true"},
+ },
+ },
+ },
+ {
+ name: "Remove Orphans in options",
+ options: libstack.DeployOptions{
+ Options: libstack.Options{
+ ProjectName: projectName,
+ },
+ RemoveOrphans: true,
+ },
+ },
+ {
+ name: "Remove Orphans in options and env",
+ options: libstack.DeployOptions{
+ Options: libstack.Options{
+ ProjectName: projectName,
+ Env: []string{cmdcompose.ComposeRemoveOrphans + "=true"},
+ },
+ RemoveOrphans: false,
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ options := tc.options.Options
+
+ err := w.Validate(ctx, filepaths, options)
+ require.NoError(t, err)
+
+ err = w.Pull(ctx, filepaths, options)
+ require.NoError(t, err)
+
+ require.False(t, containerExists(service1ContainerName))
+ require.False(t, containerExists(service2ContainerName))
+
+ err = w.Deploy(ctx, filepaths, tc.options)
+ require.NoError(t, err)
+
+ defer func() {
+ err = w.Remove(ctx, projectName, filepaths, libstack.RemoveOptions{})
+ require.NoError(t, err)
+
+ require.False(t, containerExists(service1ContainerName))
+ require.False(t, containerExists(service2ContainerName))
+ }()
+
+ require.True(t, containerExists(service1ContainerName))
+ require.True(t, containerExists(service2ContainerName))
+
+ waitResult := w.WaitForStatus(ctx, projectName, libstack.StatusCompleted)
+
+ require.Empty(t, waitResult.ErrorMsg)
+ require.Equal(t, libstack.StatusCompleted, waitResult.Status)
+
+ err = w.Validate(ctx, modifiedFilepaths, options)
+ require.NoError(t, err)
+
+ err = w.Pull(ctx, modifiedFilepaths, options)
+ require.NoError(t, err)
+
+ require.True(t, containerExists(service1ContainerName))
+ require.True(t, containerExists(service2ContainerName))
+
+ err = w.Deploy(ctx, modifiedFilepaths, tc.options)
+ require.NoError(t, err)
+
+ require.False(t, containerExists(service1ContainerName))
+ require.True(t, containerExists(service2ContainerName))
+
+ waitResult = w.WaitForStatus(ctx, projectName, libstack.StatusCompleted)
+
+ require.Empty(t, waitResult.ErrorMsg)
+ require.Equal(t, libstack.StatusCompleted, waitResult.Status)
+ })
+ }
+}
+
+func Test_DeployWithIgnoreOrphans(t *testing.T) {
+ var logOutput strings.Builder
+ oldLogger := zerolog.Logger
+ zerolog.Logger = zerolog.Output(&logOutput)
+ defer func() {
+ zerolog.Logger = oldLogger
+ }()
+
+ const projectName = "compose_ignore_orphans_test"
+
+ const composeFileContent = `services:
+ service-1:
+ image: alpine:latest
+ service-2:
+ image: alpine:latest`
+
+ const modifiedFileContent = `services:
+ service-2:
+ image: alpine:latest`
+
+ service1ContainerName := projectName + "-service-1"
+ service2ContainerName := projectName + "-service-2"
+
+ w := NewComposeDeployer()
+
+ dir := t.TempDir()
+
+ composeFilepath := createFile(t, dir, "docker-compose.yml", composeFileContent)
+ modifiedComposeFilepath := createFile(t, dir, "docker-compose-modified.yml", modifiedFileContent)
+
+ filepaths := []string{composeFilepath}
+ modifiedFilepaths := []string{modifiedComposeFilepath}
+ options := libstack.Options{
+ ProjectName: projectName,
+ Env: []string{cmdcompose.ComposeIgnoreOrphans + "=true"},
+ }
+
+ ctx := context.Background()
+
+ err := w.Validate(ctx, filepaths, options)
+ require.NoError(t, err)
+
+ err = w.Pull(ctx, filepaths, options)
+ require.NoError(t, err)
+
+ require.False(t, containerExists(service1ContainerName))
+ require.False(t, containerExists(service2ContainerName))
+
+ err = w.Deploy(ctx, filepaths, libstack.DeployOptions{Options: options})
+ require.NoError(t, err)
+
+ defer func() {
+ err = w.Remove(ctx, projectName, filepaths, libstack.RemoveOptions{})
+ require.NoError(t, err)
+
+ require.False(t, containerExists(service1ContainerName))
+ require.False(t, containerExists(service2ContainerName))
+ }()
+
+ require.True(t, containerExists(service1ContainerName))
+ require.True(t, containerExists(service2ContainerName))
+
+ waitResult := w.WaitForStatus(ctx, projectName, libstack.StatusCompleted)
+
+ require.Empty(t, waitResult.ErrorMsg)
+ require.Equal(t, libstack.StatusCompleted, waitResult.Status)
+
+ err = w.Validate(ctx, modifiedFilepaths, options)
+ require.NoError(t, err)
+
+ err = w.Pull(ctx, modifiedFilepaths, options)
+ require.NoError(t, err)
+
+ require.True(t, containerExists(service1ContainerName))
+ require.True(t, containerExists(service2ContainerName))
+
+ err = w.Deploy(ctx, modifiedFilepaths, libstack.DeployOptions{Options: options})
+ require.NoError(t, err)
+
+ require.True(t, containerExists(service1ContainerName))
+ require.True(t, containerExists(service2ContainerName))
+
+ waitResult = w.WaitForStatus(ctx, projectName, libstack.StatusCompleted)
+
+ require.Empty(t, waitResult.ErrorMsg)
+ require.Equal(t, libstack.StatusCompleted, waitResult.Status)
+
+ logString := logOutput.String()
+ require.False(t, strings.Contains(logString, "Found orphan containers ([compose_ignore_orphans_test-service-1-1])"))
+}
+
+func Test_MaxConcurrency(t *testing.T) {
+ const projectName = "compose_max_concurrency_test"
+
+ const composeFileContent = `services:
+ service-1:
+ image: alpine:latest`
+
+ w := ComposeDeployer{
+ createComposeServiceFn: createMockComposeService,
+ }
+
+ dir := t.TempDir()
+
+ composeFilepath := createFile(t, dir, "docker-compose.yml", composeFileContent)
+
+ expectedMaxConcurrency := 4
+
+ filepaths := []string{composeFilepath}
+ options := libstack.Options{
+ ProjectName: projectName,
+ Env: []string{cmdcompose.ComposeParallelLimit + "=" + strconv.Itoa(expectedMaxConcurrency)},
+ }
+
+ ctx := context.Background()
+
+ err := w.Validate(ctx, filepaths, options)
+ require.NoError(t, err)
+
+ w.withComposeService(ctx, filepaths, options, func(service api.Service, _ *types.Project) error {
+ if mockS, ok := service.(*mockComposeService); ok {
+ require.Equal(t, mockS.maxConcurrency, expectedMaxConcurrency)
+ } else {
+ t.Fatalf("Expected mockComposeService but got %T", service)
+ }
+ return nil
+ })
+}
+
+func Test_createProject(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ projectName := "create-project-test"
+
+ defer func() {
+ err := os.RemoveAll(dir)
+ if err != nil {
+ t.Fatalf("Failed to remove temp dir: %v", err)
+ }
+ }()
+
+ createTestLibstackOptions := func(workingDir, projectName string, env []string, envFilepath string) libstack.Options {
+ return libstack.Options{
+ WorkingDir: workingDir,
+ ProjectName: projectName,
+ Env: env,
+ EnvFilePath: envFilepath,
+ }
+ }
+ testSimpleComposeConfig := `services:
+ nginx:
+ container_name: nginx
+ image: nginx:latest`
+
+ expectedSimpleComposeProject := func(workingDirectory string, envOverrides map[string]string) *types.Project {
+ env := types.Mapping{consts.ComposeProjectName: "create-project-test"}
+ env = env.Merge(envOverrides)
+
+ if workingDirectory == "" {
+ workingDirectory = dir
+ }
+
+ if !filepath.IsAbs(workingDirectory) {
+ absWorkingDir, err := filepath.Abs(workingDirectory)
+ if err != nil {
+ t.Fatalf("Failed to get absolute path of working directory (%s): %v", workingDirectory, err)
+ }
+ workingDirectory = absWorkingDir
+ }
+
+ return &types.Project{
+ Name: projectName,
+ WorkingDir: workingDirectory,
+ Services: types.Services{
+ "nginx": {
+ Name: "nginx",
+ ContainerName: "nginx",
+ Environment: types.MappingWithEquals{},
+ Image: "nginx:latest",
+ Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
+ },
+ },
+ Networks: types.Networks{"default": {Name: "create-project-test_default"}},
+ ComposeFiles: []string{
+ dir + "/docker-compose.yml",
+ },
+ Environment: env,
+ DisabledServices: types.Services{},
+ Profiles: []string{""},
+ }
+ }
+
+ testComposeProfilesConfig := `services:
+ nginx:
+ container_name: nginx
+ image: nginx:latest
+ profiles: ['web1']
+ apache:
+ container_name: apache
+ image: httpd:latest
+ profiles: ['web2']`
+
+ expectedComposeProfilesProject := func(envOverrides map[string]string) *types.Project {
+ env := types.Mapping{consts.ComposeProfiles: "web1", consts.ComposeProjectName: "create-project-test"}
+ env = env.Merge(envOverrides)
+
+ return &types.Project{
+ Name: projectName,
+ WorkingDir: dir,
+ Services: types.Services{
+ "nginx": {
+ Name: "nginx",
+ Profiles: []string{"web1"},
+ ContainerName: "nginx",
+ Environment: types.MappingWithEquals{},
+ Image: "nginx:latest",
+ Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
+ },
+ },
+ Networks: types.Networks{"default": {Name: "create-project-test_default"}},
+ ComposeFiles: []string{
+ dir + "/docker-compose.yml",
+ },
+ Environment: env,
+ DisabledServices: types.Services{
+ "apache": {
+ Name: "apache",
+ Profiles: []string{"web2"},
+ ContainerName: "apache",
+ Environment: nil,
+ Image: "httpd:latest",
+ Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
+ },
+ },
+ Profiles: []string{"web1"},
+ }
+ }
+
+ testcases := []struct {
+ name string
+ filesToCreate map[string]string
+ configFilepaths []string
+ options libstack.Options
+ expectedProject *types.Project
+ }{
+ {
+ name: "Compose profiles in env",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testComposeProfilesConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeProfiles + "=web1"}, ""),
+ expectedProject: expectedComposeProfilesProject(nil),
+ },
+ {
+ name: "Compose profiles in env file",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testComposeProfilesConfig,
+ "stack.env": consts.ComposeProfiles + "=web1",
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: createTestLibstackOptions(dir, projectName, nil, dir+"/stack.env"),
+ expectedProject: expectedComposeProfilesProject(nil),
+ },
+ {
+ name: "Compose profiles in env file in COMPOSE_ENV_FILES",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testComposeProfilesConfig,
+ "stack.env": consts.ComposeProfiles + "=web1",
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: createTestLibstackOptions(dir, projectName, []string{cmdcompose.ComposeEnvFiles + "=" + dir + "/stack.env"}, ""),
+ expectedProject: expectedComposeProfilesProject(map[string]string{
+ cmdcompose.ComposeEnvFiles: dir + "/stack.env",
+ }),
+ },
+ {
+ name: "Compose profiles in both env and env file",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testComposeProfilesConfig,
+ "stack.env": consts.ComposeProfiles + "=web2",
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeProfiles + "=web1"}, dir+"/stack.env"),
+ expectedProject: expectedComposeProfilesProject(nil),
+ },
+ {
+ name: "Compose project name in both options and env",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeProjectName + "=totally_different_name"}, ""),
+ expectedProject: expectedSimpleComposeProject("", nil),
+ },
+ {
+ name: "Compose project name in only env",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: createTestLibstackOptions(dir, "", []string{consts.ComposeProjectName + "=totally_different_name"}, ""),
+ expectedProject: &types.Project{
+ Name: "totally_different_name",
+ WorkingDir: dir,
+ Services: types.Services{
+ "nginx": {
+ Name: "nginx",
+ ContainerName: "nginx",
+ Environment: types.MappingWithEquals{},
+ Image: "nginx:latest",
+ Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
+ },
+ },
+ Networks: types.Networks{"default": {Name: "totally_different_name_default"}},
+ ComposeFiles: []string{
+ dir + "/docker-compose.yml",
+ },
+ Environment: types.Mapping{consts.ComposeProjectName: "totally_different_name"},
+ DisabledServices: types.Services{},
+ Profiles: []string{""},
+ },
+ },
+ {
+ name: "Compose files in env",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ },
+ configFilepaths: nil,
+ options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeFilePath + "=" + dir + "/docker-compose.yml"}, ""),
+ expectedProject: expectedSimpleComposeProject("", map[string]string{
+ consts.ComposeFilePath: dir + "/docker-compose.yml",
+ }),
+ },
+ {
+ name: "Compose files in both options and env",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ "profiles-docker-compose.yml": testComposeProfilesConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeFilePath + "=" + dir + "/profiles-docker-compose.yml"}, ""),
+ expectedProject: expectedSimpleComposeProject("", map[string]string{
+ consts.ComposeFilePath: dir + "/profiles-docker-compose.yml",
+ }),
+ },
+ {
+ name: "Multiple Compose files in options",
+ filesToCreate: map[string]string{
+ "docker-compose-0.yml": `services:
+ nginx:
+ container_name: nginx
+ image: nginx:latest`,
+ "docker-compose-1.yml": `services:
+ apache:
+ container_name: apache
+ image: httpd:latest`,
+ },
+ configFilepaths: []string{dir + "/docker-compose-0.yml", dir + "/docker-compose-1.yml"},
+ options: createTestLibstackOptions(dir, projectName, []string{}, ""),
+ expectedProject: &types.Project{
+ Name: projectName,
+ WorkingDir: dir,
+ Services: types.Services{
+ "nginx": {
+ Name: "nginx",
+ ContainerName: "nginx",
+ Environment: types.MappingWithEquals{},
+ Image: "nginx:latest",
+ Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
+ },
+ "apache": {
+ Name: "apache",
+ ContainerName: "apache",
+ Environment: types.MappingWithEquals{},
+ Image: "httpd:latest",
+ Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
+ },
+ },
+ Networks: types.Networks{"default": {Name: "create-project-test_default"}},
+ ComposeFiles: []string{
+ dir + "/docker-compose-0.yml",
+ dir + "/docker-compose-1.yml",
+ },
+ Environment: types.Mapping{consts.ComposeProjectName: "create-project-test"},
+ DisabledServices: types.Services{},
+ Profiles: []string{""},
+ },
+ },
+ {
+ name: "Multiple Compose files in env",
+ filesToCreate: map[string]string{
+ "docker-compose-0.yml": `services:
+ nginx:
+ container_name: nginx
+ image: nginx:latest`,
+ "docker-compose-1.yml": `services:
+ apache:
+ container_name: apache
+ image: httpd:latest`,
+ },
+ configFilepaths: nil,
+ options: createTestLibstackOptions(dir, projectName, []string{consts.ComposeFilePath + "=" + dir + "/docker-compose-0.yml:" + dir + "/docker-compose-1.yml"}, ""),
+ expectedProject: &types.Project{
+ Name: projectName,
+ WorkingDir: dir,
+ Services: types.Services{
+ "nginx": {
+ Name: "nginx",
+ ContainerName: "nginx",
+ Environment: types.MappingWithEquals{},
+ Image: "nginx:latest",
+ Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
+ },
+ "apache": {
+ Name: "apache",
+ ContainerName: "apache",
+ Environment: types.MappingWithEquals{},
+ Image: "httpd:latest",
+ Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
+ },
+ },
+ Networks: types.Networks{"default": {Name: "create-project-test_default"}},
+ ComposeFiles: []string{
+ dir + "/docker-compose-0.yml",
+ dir + "/docker-compose-1.yml",
+ },
+ Environment: types.Mapping{consts.ComposeProjectName: "create-project-test", consts.ComposeFilePath: dir + "/docker-compose-0.yml:" + dir + "/docker-compose-1.yml"},
+ DisabledServices: types.Services{},
+ Profiles: []string{""},
+ },
+ },
+ {
+ name: "Multiple Compose files in env with COMPOSE_PATH_SEPARATOR",
+ filesToCreate: map[string]string{
+ "docker-compose-0.yml": `services:
+ nginx:
+ container_name: nginx
+ image: nginx:latest`,
+ "docker-compose-1.yml": `services:
+ apache:
+ container_name: apache
+ image: httpd:latest`,
+ },
+ configFilepaths: nil,
+ options: createTestLibstackOptions(dir, projectName, []string{consts.ComposePathSeparator + "=|", consts.ComposeFilePath + "=" + dir + "/docker-compose-0.yml|" + dir + "/docker-compose-1.yml"}, ""),
+ expectedProject: &types.Project{
+ Name: projectName,
+ WorkingDir: dir,
+ Services: types.Services{
+ "nginx": {
+ Name: "nginx",
+ ContainerName: "nginx",
+ Environment: types.MappingWithEquals{},
+ Image: "nginx:latest",
+ Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
+ },
+ "apache": {
+ Name: "apache",
+ ContainerName: "apache",
+ Environment: types.MappingWithEquals{},
+ Image: "httpd:latest",
+ Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
+ },
+ },
+ Networks: types.Networks{"default": {Name: "create-project-test_default"}},
+ ComposeFiles: []string{
+ dir + "/docker-compose-0.yml",
+ dir + "/docker-compose-1.yml",
+ },
+ Environment: types.Mapping{consts.ComposeProjectName: "create-project-test", consts.ComposePathSeparator: "|", consts.ComposeFilePath: dir + "/docker-compose-0.yml|" + dir + "/docker-compose-1.yml"},
+ DisabledServices: types.Services{},
+ Profiles: []string{""},
+ },
+ },
+ {
+ name: "compose ignore orphans",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: createTestLibstackOptions(dir, projectName, []string{cmdcompose.ComposeIgnoreOrphans + "=true"}, ""),
+ expectedProject: expectedSimpleComposeProject("", map[string]string{
+ cmdcompose.ComposeIgnoreOrphans: "true",
+ }),
+ },
+ {
+ name: "compose remove orphans",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: createTestLibstackOptions(dir, projectName, []string{cmdcompose.ComposeRemoveOrphans + "=true"}, ""),
+ expectedProject: expectedSimpleComposeProject("", map[string]string{
+ cmdcompose.ComposeRemoveOrphans: "true",
+ }),
+ },
+ {
+ name: "compose parallel limit",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: createTestLibstackOptions(dir, projectName, []string{cmdcompose.ComposeParallelLimit + "=true"}, ""),
+ expectedProject: expectedSimpleComposeProject("", map[string]string{
+ cmdcompose.ComposeParallelLimit: "true",
+ }),
+ },
+ {
+ name: "Absolute Working Directory",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: libstack.Options{
+ WorkingDir: "/something-totally-different",
+ ProjectName: projectName,
+ },
+ expectedProject: expectedSimpleComposeProject("/something-totally-different", nil),
+ },
+ {
+ name: "Relative Working Directory",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: libstack.Options{
+ WorkingDir: "something-totally-different",
+ ProjectName: projectName,
+ },
+ expectedProject: expectedSimpleComposeProject("something-totally-different", nil),
+ },
+ {
+ name: "Absolute Project Directory",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: libstack.Options{
+ ProjectDir: "/something-totally-different",
+ ProjectName: projectName,
+ },
+ expectedProject: expectedSimpleComposeProject("/something-totally-different", nil),
+ },
+ {
+ name: "Relative Project Directory",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: libstack.Options{
+ ProjectDir: "something-totally-different",
+ ProjectName: projectName,
+ },
+ expectedProject: expectedSimpleComposeProject("something-totally-different", nil),
+ },
+ {
+ name: "Absolute Project and Working Directory set",
+ filesToCreate: map[string]string{
+ "docker-compose.yml": testSimpleComposeConfig,
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: libstack.Options{
+ WorkingDir: "/working-dir",
+ ProjectDir: "/project-dir",
+ ProjectName: projectName,
+ },
+ expectedProject: expectedSimpleComposeProject("/project-dir", nil),
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ createdFiles := make([]string, 0, len(tc.filesToCreate))
+ for f, fc := range tc.filesToCreate {
+ createdFiles = append(createdFiles, createFile(t, dir, f, fc))
+ }
+
+ defer func() {
+ var errs []error
+ for _, f := range createdFiles {
+ errs = append(errs, os.Remove(f))
+ }
+
+ err := errors.Join(errs...)
+ if err != nil {
+ t.Fatalf("Failed to remove config files: %v", err)
+ }
+ }()
+
+ gotProject, err := createProject(ctx, tc.configFilepaths, tc.options)
+ if err != nil {
+ t.Fatalf("Failed to create new project: %v", err)
+ }
+
+ if diff := cmp.Diff(gotProject, tc.expectedProject); diff != "" {
+ t.Fatalf("Projects are different:\n%s", diff)
+ }
+ })
+ }
+}
+
+func createMockComposeService(dockerCli command.Cli) api.Service {
+ return &mockComposeService{}
+}
+
+type mockComposeService struct {
+ api.Service
+ maxConcurrency int
+}
+
+func (s *mockComposeService) MaxConcurrency(parallel int) {
+ s.maxConcurrency = parallel
+}
diff --git a/pkg/libstack/compose/composeplugin_windows_test.go b/pkg/libstack/compose/composeplugin_windows_test.go
new file mode 100644
index 000000000..c6f3f196a
--- /dev/null
+++ b/pkg/libstack/compose/composeplugin_windows_test.go
@@ -0,0 +1,109 @@
+package compose
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/compose-spec/compose-go/v2/types"
+ "github.com/google/go-cmp/cmp"
+ "github.com/portainer/portainer/pkg/libstack"
+)
+
+func Test_createProject_win(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ projectName := "create-project-test"
+
+ defer func() {
+ err := os.RemoveAll(dir)
+ if err != nil {
+ t.Fatalf("Failed to remove temp dir: %v", err)
+ }
+ }()
+
+ testcases := []struct {
+ name string
+ createFilesFn func() []string
+ configFilepaths []string
+ options libstack.Options
+ expectedProject *types.Project
+ }{
+ {
+ name: "Convert windows paths",
+ createFilesFn: func() []string {
+ var filepaths []string
+ filepaths = append(filepaths, createFile(t, dir, "docker-compose.yml", `services:
+ nginx:
+ container_name: nginx
+ image: nginx:latest
+ volumes:
+ - "C:\\Users\\Joey\\Desktop\\backend:/var/www/html"`))
+ return filepaths
+ },
+ configFilepaths: []string{dir + "/docker-compose.yml"},
+ options: libstack.Options{
+ WorkingDir: dir,
+ ProjectName: projectName,
+ Env: []string{"COMPOSE_CONVERT_WINDOWS_PATHS=true"},
+ },
+ expectedProject: &types.Project{
+ Name: projectName,
+ WorkingDir: dir,
+ Services: types.Services{
+ "nginx": {
+ Name: "nginx",
+ ContainerName: "nginx",
+ Environment: types.MappingWithEquals{},
+ Image: "nginx:latest",
+ Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
+ Volumes: []types.ServiceVolumeConfig{
+ {
+ Type: "bind",
+ Source: "/c/Users/Joey/Desktop/backend",
+ Target: "/var/www/html",
+ ReadOnly: false,
+ Bind: &types.ServiceVolumeBind{CreateHostPath: true},
+ },
+ },
+ },
+ },
+ Networks: types.Networks{"default": {Name: "create-project-test_default"}},
+ ComposeFiles: []string{
+ dir + "/docker-compose.yml",
+ },
+ Environment: types.Mapping{"COMPOSE_PROJECT_NAME": "create-project-test", "COMPOSE_CONVERT_WINDOWS_PATHS": "true"},
+ DisabledServices: types.Services{},
+ Profiles: []string{""},
+ },
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ createdFiles := tc.createFilesFn()
+
+ defer func() {
+ var errs []error
+ for _, f := range createdFiles {
+ errs = append(errs, os.Remove(f))
+ }
+
+ err := errors.Join(errs...)
+ if err != nil {
+ t.Fatalf("Failed to remove config files: %v", err)
+ }
+ }()
+
+ gotProject, err := createProject(ctx, tc.configFilepaths, tc.options)
+ if err != nil {
+ t.Fatalf("Failed to create new project: %v", err)
+ }
+
+ if diff := cmp.Diff(gotProject, tc.expectedProject); diff != "" {
+ t.Fatalf("Projects are different:\n%s", diff)
+ }
+ })
+ }
+}
diff --git a/pkg/libstack/compose/status.go b/pkg/libstack/compose/status.go
index bbbe2264b..b6ad44ebf 100644
--- a/pkg/libstack/compose/status.go
+++ b/pkg/libstack/compose/status.go
@@ -125,7 +125,7 @@ func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status
var containerSummaries []api.ContainerSummary
- if err := withComposeService(ctx, nil, libstack.Options{ProjectName: name}, func(composeService api.Service, project *types.Project) error {
+ if err := c.withComposeService(ctx, nil, libstack.Options{ProjectName: name}, func(composeService api.Service, project *types.Project) error {
var err error
psCtx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
From 0dfde1374d321d1eeb37e0da3ba8bd4d83e2c541 Mon Sep 17 00:00:00 2001
From: James Player
Date: Tue, 25 Mar 2025 10:59:28 +1300
Subject: [PATCH 060/212] fix(kubernetes): Cluster reservation CPU not showing
R8S-268 (#569)
---
.../cluster/ClusterView/ClusterResourceReservation.test.tsx | 2 +-
.../ClusterView/queries/useClusterResourceReservationQuery.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx b/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx
index ebeecc6c4..b243ab1d4 100644
--- a/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx
+++ b/app/react/kubernetes/cluster/ClusterView/ClusterResourceReservation.test.tsx
@@ -78,7 +78,7 @@ describe('ClusterResourceReservation', () => {
),
http.get('/api/kubernetes/3/metrics/applications_resources', () =>
HttpResponse.json({
- CpuRequest: 1000,
+ CpuRequest: 1,
MemoryRequest: '2Gi',
})
)
diff --git a/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceReservationQuery.ts b/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceReservationQuery.ts
index 50c68fd7d..8479212e1 100644
--- a/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceReservationQuery.ts
+++ b/app/react/kubernetes/cluster/ClusterView/queries/useClusterResourceReservationQuery.ts
@@ -15,7 +15,7 @@ export function useClusterResourceReservationQuery(
{
enabled: !!environmentId && nodes.length > 0,
select: (data) => ({
- cpu: data.CpuRequest / 1000,
+ cpu: data.CpuRequest,
memory: KubernetesResourceReservationHelper.megaBytesValue(
data.MemoryRequest
),
From 995c3ef81bfe8bd1a86d129f11e48fde2acb084e Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Mon, 24 Mar 2025 19:33:05 -0300
Subject: [PATCH 061/212] feat(snapshots): avoid parsing raw snapshots when
possible BE-11724 (#560)
---
api/dataservices/interface.go | 1 +
api/dataservices/snapshot/snapshot.go | 13 +++++++++++
api/dataservices/snapshot/tx.go | 23 +++++++++++++++++++
.../handler/endpoints/endpoint_inspect.go | 19 +++++++--------
api/http/handler/endpoints/endpoint_list.go | 4 +++-
api/http/handler/endpoints/endpoint_update.go | 2 +-
api/http/handler/system/nodes_count.go | 2 +-
api/http/proxy/factory/docker/volumes.go | 2 +-
api/internal/snapshot/snapshot.go | 16 +++++++++----
api/portainer.go | 2 +-
app/react/hooks/useCurrentEnvironment.ts | 2 +-
.../EnvironmentItem/EngineVersion.tsx | 4 ++--
.../EnvironmentList/EnvironmentList.tsx | 1 +
.../environments/environment.service/index.ts | 11 +++++++--
.../environments/queries/useEnvironment.ts | 13 +++++++++--
.../common/useEnvironments.ts | 2 +-
16 files changed, 89 insertions(+), 28 deletions(-)
diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go
index 2bc2df7f3..8ba55531c 100644
--- a/api/dataservices/interface.go
+++ b/api/dataservices/interface.go
@@ -159,6 +159,7 @@ type (
SnapshotService interface {
BaseCRUD[portainer.Snapshot, portainer.EndpointID]
+ ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error)
}
// SSLSettingsService represents a service for managing application settings
diff --git a/api/dataservices/snapshot/snapshot.go b/api/dataservices/snapshot/snapshot.go
index 1f9cd5f9f..155077677 100644
--- a/api/dataservices/snapshot/snapshot.go
+++ b/api/dataservices/snapshot/snapshot.go
@@ -38,3 +38,16 @@ func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
func (service *Service) Create(snapshot *portainer.Snapshot) error {
return service.Connection.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
}
+
+func (service *Service) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error) {
+ var snapshot *portainer.Snapshot
+
+ err := service.Connection.ViewTx(func(tx portainer.Transaction) error {
+ var err error
+ snapshot, err = service.Tx(tx).ReadWithoutSnapshotRaw(ID)
+
+ return err
+ })
+
+ return snapshot, err
+}
diff --git a/api/dataservices/snapshot/tx.go b/api/dataservices/snapshot/tx.go
index c93a747d3..8a8dcc1c2 100644
--- a/api/dataservices/snapshot/tx.go
+++ b/api/dataservices/snapshot/tx.go
@@ -12,3 +12,26 @@ type ServiceTx struct {
func (service ServiceTx) Create(snapshot *portainer.Snapshot) error {
return service.Tx.CreateObjectWithId(BucketName, int(snapshot.EndpointID), snapshot)
}
+
+func (service ServiceTx) ReadWithoutSnapshotRaw(ID portainer.EndpointID) (*portainer.Snapshot, error) {
+ var snapshot struct {
+ Docker *struct {
+ X struct{} `json:"DockerSnapshotRaw"`
+ *portainer.DockerSnapshot
+ } `json:"Docker"`
+
+ portainer.Snapshot
+ }
+
+ identifier := service.Connection.ConvertToKey(int(ID))
+
+ if err := service.Tx.GetObject(service.Bucket, identifier, &snapshot); err != nil {
+ return nil, err
+ }
+
+ if snapshot.Docker != nil {
+ snapshot.Snapshot.Docker = snapshot.Docker.DockerSnapshot
+ }
+
+ return &snapshot.Snapshot, nil
+}
diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go
index e25e79720..132ab6350 100644
--- a/api/http/handler/endpoints/endpoint_inspect.go
+++ b/api/http/handler/endpoints/endpoint_inspect.go
@@ -19,6 +19,8 @@ import (
// @security jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
+// @param excludeSnapshot query bool false "if true, the snapshot data won't be retrieved"
+// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
// @success 200 {object} portainer.Endpoint "Success"
// @failure 400 "Invalid request"
// @failure 404 "Environment(Endpoint) not found"
@@ -37,8 +39,7 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
}
- err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
- if err != nil {
+ if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
@@ -51,9 +52,11 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
endpointutils.UpdateEdgeEndpointHeartbeat(endpoint, settings)
endpoint.ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
- if !excludeSnapshot(r) {
- err = handler.SnapshotService.FillSnapshotData(endpoint)
- if err != nil {
+ excludeSnapshot, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshot", true)
+ excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
+
+ if !excludeSnapshot {
+ if err := handler.SnapshotService.FillSnapshotData(endpoint, !excludeRaw); err != nil {
return httperror.InternalServerError("Unable to add snapshot data", err)
}
}
@@ -83,9 +86,3 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
return response.JSON(w, endpoint)
}
-
-func excludeSnapshot(r *http.Request) bool {
- excludeSnapshot, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshot", true)
-
- return excludeSnapshot
-}
diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go
index 7ea557910..6be1c7ff2 100644
--- a/api/http/handler/endpoints/endpoint_list.go
+++ b/api/http/handler/endpoints/endpoint_list.go
@@ -44,6 +44,7 @@ const (
// @param edgeDeviceUntrusted query bool false "if true, show only untrusted edge agents, if false show only trusted edge agents (relevant only for edge agents)"
// @param edgeCheckInPassedSeconds query number false "if bigger then zero, show only edge agents that checked-in in the last provided seconds (relevant only for edge agents)"
// @param excludeSnapshots query bool false "if true, the snapshot data won't be retrieved"
+// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
// @param name query string false "will return only environments(endpoints) with this name"
// @param edgeStackId query portainer.EdgeStackID false "will return the environements of the specified edge stack"
// @param edgeStackStatus query string false "only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!)" Enum("Pending", "Ok", "Error", "Acknowledged", "Remove", "RemoteUpdateSuccess", "ImagesPulled")
@@ -59,6 +60,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
+ excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll()
if err != nil {
@@ -114,7 +116,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
endpointutils.UpdateEdgeEndpointHeartbeat(&paginatedEndpoints[idx], settings)
if !query.excludeSnapshots {
- if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx]); err != nil {
+ if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], !excludeRaw); err != nil {
return httperror.InternalServerError("Unable to add snapshot data", err)
}
}
diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go
index 0f57136df..71c9ef702 100644
--- a/api/http/handler/endpoints/endpoint_update.go
+++ b/api/http/handler/endpoints/endpoint_update.go
@@ -272,7 +272,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
}
}
- if err := handler.SnapshotService.FillSnapshotData(endpoint); err != nil {
+ if err := handler.SnapshotService.FillSnapshotData(endpoint, true); err != nil {
return httperror.InternalServerError("Unable to add snapshot data", err)
}
diff --git a/api/http/handler/system/nodes_count.go b/api/http/handler/system/nodes_count.go
index 94ab320fa..7d150d911 100644
--- a/api/http/handler/system/nodes_count.go
+++ b/api/http/handler/system/nodes_count.go
@@ -33,7 +33,7 @@ func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request)
var nodes int
for _, endpoint := range endpoints {
- if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint); err != nil {
+ if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint, false); err != nil {
return httperror.InternalServerError("Unable to add snapshot data", err)
}
diff --git a/api/http/proxy/factory/docker/volumes.go b/api/http/proxy/factory/docker/volumes.go
index aae0a4602..858a940d8 100644
--- a/api/http/proxy/factory/docker/volumes.go
+++ b/api/http/proxy/factory/docker/volumes.go
@@ -224,7 +224,7 @@ func (transport *Transport) getDockerID() (string, error) {
if transport.snapshotService != nil {
endpoint := portainer.Endpoint{ID: transport.endpoint.ID}
- if err := transport.snapshotService.FillSnapshotData(&endpoint); err == nil && len(endpoint.Snapshots) > 0 {
+ if err := transport.snapshotService.FillSnapshotData(&endpoint, true); err == nil && len(endpoint.Snapshots) > 0 {
if dockerID, err := snapshot.FetchDockerID(endpoint.Snapshots[0]); err == nil {
transport.dockerID = dockerID
return dockerID, nil
diff --git a/api/internal/snapshot/snapshot.go b/api/internal/snapshot/snapshot.go
index 019dec359..7bc109459 100644
--- a/api/internal/snapshot/snapshot.go
+++ b/api/internal/snapshot/snapshot.go
@@ -170,8 +170,8 @@ func (service *Service) Create(snapshot portainer.Snapshot) error {
return service.dataStore.Snapshot().Create(&snapshot)
}
-func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint) error {
- return FillSnapshotData(service.dataStore, endpoint)
+func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint, includeRaw bool) error {
+ return FillSnapshotData(service.dataStore, endpoint, includeRaw)
}
func (service *Service) snapshotKubernetesEndpoint(endpoint *portainer.Endpoint) error {
@@ -328,8 +328,16 @@ func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) {
return info.Swarm.Cluster.ID, nil
}
-func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
- snapshot, err := tx.Snapshot().Read(endpoint.ID)
+func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, includeRaw bool) error {
+ var snapshot *portainer.Snapshot
+ var err error
+
+ if includeRaw {
+ snapshot, err = tx.Snapshot().Read(endpoint.ID)
+ } else {
+ snapshot, err = tx.Snapshot().ReadWithoutSnapshotRaw(endpoint.ID)
+ }
+
if tx.IsErrObjectNotFound(err) {
endpoint.Snapshots = []portainer.DockerSnapshot{}
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
diff --git a/api/portainer.go b/api/portainer.go
index b90e89b4e..1f57b0ada 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -1622,7 +1622,7 @@ type (
Start()
SetSnapshotInterval(snapshotInterval string) error
SnapshotEndpoint(endpoint *Endpoint) error
- FillSnapshotData(endpoint *Endpoint) error
+ FillSnapshotData(endpoint *Endpoint, includeRaw bool) error
}
// SwarmStackManager represents a service to manage Swarm stacks
diff --git a/app/react/hooks/useCurrentEnvironment.ts b/app/react/hooks/useCurrentEnvironment.ts
index 7beaf6fb6..c65ca077b 100644
--- a/app/react/hooks/useCurrentEnvironment.ts
+++ b/app/react/hooks/useCurrentEnvironment.ts
@@ -4,5 +4,5 @@ import { useEnvironmentId } from './useEnvironmentId';
export function useCurrentEnvironment(force = true) {
const id = useEnvironmentId(force);
- return useEnvironment(id);
+ return useEnvironment(id, undefined, { excludeSnapshot: false });
}
diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EngineVersion.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EngineVersion.tsx
index 83e337686..30338d656 100644
--- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EngineVersion.tsx
+++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentItem/EngineVersion.tsx
@@ -1,16 +1,16 @@
import { DockerSnapshot } from '@/react/docker/snapshots/types';
-import { useIsPodman } from '@/react/portainer/environments/queries/useIsPodman';
import {
Environment,
PlatformType,
KubernetesSnapshot,
+ ContainerEngine,
} from '@/react/portainer/environments/types';
import { getPlatformType } from '@/react/portainer/environments/utils';
import { getDockerEnvironmentType } from '@/react/portainer/environments/utils/getDockerEnvironmentType';
export function EngineVersion({ environment }: { environment: Environment }) {
const platform = getPlatformType(environment.Type);
- const isPodman = useIsPodman(environment.Id);
+ const isPodman = environment.ContainerEngine === ContainerEngine.Podman;
switch (platform) {
case PlatformType.Docker:
diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx
index 9884048b3..160e68684 100644
--- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx
+++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx
@@ -110,6 +110,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
updateInformation: isBE,
edgeAsync: getEdgeAsyncValue(connectionTypes),
platformTypes,
+ excludeSnapshotRaw: true,
};
const queryWithSort = {
diff --git a/app/react/portainer/environments/environment.service/index.ts b/app/react/portainer/environments/environment.service/index.ts
index f96d57d69..c6d770e74 100644
--- a/app/react/portainer/environments/environment.service/index.ts
+++ b/app/react/portainer/environments/environment.service/index.ts
@@ -42,6 +42,7 @@ export interface BaseEnvironmentsQueryParams {
edgeAsync?: boolean;
edgeDeviceUntrusted?: boolean;
excludeSnapshots?: boolean;
+ excludeSnapshotRaw?: boolean;
provisioned?: boolean;
name?: string;
agentVersions?: string[];
@@ -119,9 +120,15 @@ export async function getAgentVersions() {
}
}
-export async function getEndpoint(id: EnvironmentId) {
+export async function getEndpoint(
+ id: EnvironmentId,
+ excludeSnapshot = true,
+ excludeSnapshotRaw = true
+) {
try {
- const { data: endpoint } = await axios.get(buildUrl(id));
+ const { data: endpoint } = await axios.get(buildUrl(id), {
+ params: { excludeSnapshot, excludeSnapshotRaw },
+ });
return endpoint;
} catch (e) {
throw parseAxiosError(e as Error);
diff --git a/app/react/portainer/environments/queries/useEnvironment.ts b/app/react/portainer/environments/queries/useEnvironment.ts
index 7a0b4961c..b2b8fd6bd 100644
--- a/app/react/portainer/environments/queries/useEnvironment.ts
+++ b/app/react/portainer/environments/queries/useEnvironment.ts
@@ -10,11 +10,20 @@ import { environmentQueryKeys } from './query-keys';
export function useEnvironment(
environmentId?: EnvironmentId,
select?: (environment: Environment) => T,
- options?: { autoRefreshRate?: number }
+ options?: {
+ autoRefreshRate?: number;
+ excludeSnapshot?: boolean;
+ excludeSnapshotRaw?: boolean;
+ }
) {
return useQuery(
environmentQueryKeys.item(environmentId!),
- () => getEndpoint(environmentId!),
+ () =>
+ getEndpoint(
+ environmentId!,
+ options?.excludeSnapshot ?? undefined,
+ options?.excludeSnapshotRaw ?? undefined
+ ),
{
select,
...withError('Failed loading environment'),
diff --git a/app/react/portainer/environments/update-schedules/common/useEnvironments.ts b/app/react/portainer/environments/update-schedules/common/useEnvironments.ts
index bfe9a83b4..7843cf27b 100644
--- a/app/react/portainer/environments/update-schedules/common/useEnvironments.ts
+++ b/app/react/portainer/environments/update-schedules/common/useEnvironments.ts
@@ -3,7 +3,7 @@ import { EdgeGroupId, EdgeTypes } from '@/react/portainer/environments/types';
export function useEnvironments(edgeGroupIds: Array) {
const environmentsQuery = useEnvironmentList(
- { edgeGroupIds, types: EdgeTypes, pageLimit: 0 },
+ { edgeGroupIds, types: EdgeTypes, pageLimit: 0, excludeSnapshots: true },
{
enabled: edgeGroupIds.length > 0,
}
From cdd9851f72d47a2d5e7f74ede293e4fc6046d66e Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Mon, 24 Mar 2025 19:56:08 -0300
Subject: [PATCH 062/212] fix(stubs): clean up the stubs and mocks BE-11722
(#557)
---
api/exec/exectest/kubernetes_mocks.go | 14 ++----
api/internal/testhelpers/datastore.go | 72 +++++++--------------------
2 files changed, 23 insertions(+), 63 deletions(-)
diff --git a/api/exec/exectest/kubernetes_mocks.go b/api/exec/exectest/kubernetes_mocks.go
index 22638216e..894e52135 100644
--- a/api/exec/exectest/kubernetes_mocks.go
+++ b/api/exec/exectest/kubernetes_mocks.go
@@ -4,17 +4,11 @@ import (
portainer "github.com/portainer/portainer/api"
)
-type kubernetesMockDeployer struct{}
+type kubernetesMockDeployer struct {
+ portainer.KubernetesDeployer
+}
// NewKubernetesDeployer creates a mock kubernetes deployer
-func NewKubernetesDeployer() portainer.KubernetesDeployer {
+func NewKubernetesDeployer() *kubernetesMockDeployer {
return &kubernetesMockDeployer{}
}
-
-func (deployer *kubernetesMockDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
- return "", nil
-}
-
-func (deployer *kubernetesMockDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
- return "", nil
-}
diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go
index 9a16ddfd2..d4a29ae09 100644
--- a/api/internal/testhelpers/datastore.go
+++ b/api/internal/testhelpers/datastore.go
@@ -110,9 +110,11 @@ type datastoreOption = func(d *testDatastore)
func NewDatastore(options ...datastoreOption) *testDatastore {
conn, _ := database.NewDatabase("boltdb", "", nil)
d := testDatastore{connection: conn}
+
for _, o := range options {
o(&d)
}
+
return &d
}
@@ -128,6 +130,7 @@ func (s *stubSettingsService) Settings() (*portainer.Settings, error) {
func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error {
s.settings = settings
+
return nil
}
@@ -140,20 +143,16 @@ func WithSettingsService(settings *portainer.Settings) datastoreOption {
}
type stubUserService struct {
+ dataservices.UserService
+
users []portainer.User
}
-func (s *stubUserService) BucketName() string { return "users" }
-func (s *stubUserService) Read(ID portainer.UserID) (*portainer.User, error) { return nil, nil }
-func (s *stubUserService) UserByUsername(username string) (*portainer.User, error) { return nil, nil }
-func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil }
+func (s *stubUserService) BucketName() string { return "users" }
+func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil }
func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
return s.users, nil
}
-func (s *stubUserService) Create(user *portainer.User) error { return nil }
-func (s *stubUserService) Update(ID portainer.UserID, user *portainer.User) error { return nil }
-func (s *stubUserService) Delete(ID portainer.UserID) error { return nil }
-func (s *stubUserService) Exists(ID portainer.UserID) (bool, error) { return false, nil }
// WithUsers testDatastore option that will instruct testDatastore to return provided users
func WithUsers(us []portainer.User) datastoreOption {
@@ -163,35 +162,13 @@ func WithUsers(us []portainer.User) datastoreOption {
}
type stubEdgeJobService struct {
+ dataservices.EdgeJobService
+
jobs []portainer.EdgeJob
}
func (s *stubEdgeJobService) BucketName() string { return "edgejobs" }
func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.jobs, nil }
-func (s *stubEdgeJobService) Read(ID portainer.EdgeJobID) (*portainer.EdgeJob, error) {
- return nil, nil
-}
-
-func (s *stubEdgeJobService) Create(edgeJob *portainer.EdgeJob) error {
- return nil
-}
-
-func (s *stubEdgeJobService) CreateWithID(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
- return nil
-}
-
-func (s *stubEdgeJobService) Update(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
- return nil
-}
-
-func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFunc func(edgeJob *portainer.EdgeJob)) error {
- return nil
-}
-func (s *stubEdgeJobService) Delete(ID portainer.EdgeJobID) error { return nil }
-func (s *stubEdgeJobService) GetNextIdentifier() int { return 0 }
-func (s *stubEdgeJobService) Exists(ID portainer.EdgeJobID) (bool, error) {
- return false, nil
-}
// WithEdgeJobs option will instruct testDatastore to return provided jobs
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
@@ -201,6 +178,8 @@ func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
}
type stubEndpointRelationService struct {
+ dataservices.EndpointRelationService
+
relations []portainer.EndpointRelation
}
@@ -219,10 +198,6 @@ func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID)
return nil, errors.ErrObjectNotFound
}
-func (s *stubEndpointRelationService) Create(EndpointRelation *portainer.EndpointRelation) error {
- return nil
-}
-
func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.EndpointID, relation *portainer.EndpointRelation) error {
for i, r := range s.relations {
if r.EndpointID == ID {
@@ -257,11 +232,6 @@ func (s *stubEndpointRelationService) RemoveEndpointRelationsForEdgeStack(endpoi
return nil
}
-func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
- return nil
-}
-func (s *stubEndpointRelationService) GetNextIdentifier() int { return 0 }
-
// WithEndpointRelations option will instruct testDatastore to return provided jobs
func WithEndpointRelations(relations []portainer.EndpointRelation) datastoreOption {
return func(d *testDatastore) {
@@ -360,6 +330,7 @@ func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]port
}
}
}
+
return endpoints, nil
}
@@ -371,29 +342,19 @@ func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption {
}
type stubStacksService struct {
+ dataservices.StackService
stacks []portainer.Stack
}
func (s *stubStacksService) BucketName() string { return "stacks" }
-func (s *stubStacksService) Create(stack *portainer.Stack) error {
- return nil
-}
-
-func (s *stubStacksService) Update(ID portainer.StackID, stack *portainer.Stack) error {
- return nil
-}
-
-func (s *stubStacksService) Delete(ID portainer.StackID) error {
- return nil
-}
-
func (s *stubStacksService) Read(ID portainer.StackID) (*portainer.Stack, error) {
for _, stack := range s.stacks {
if stack.ID == ID {
return &stack, nil
}
}
+
return nil, errors.ErrObjectNotFound
}
@@ -409,6 +370,7 @@ func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID)
result = append(result, stack)
}
}
+
return result, nil
}
@@ -420,6 +382,7 @@ func (s *stubStacksService) RefreshableStacks() ([]portainer.Stack, error) {
result = append(result, stack)
}
}
+
return result, nil
}
@@ -429,6 +392,7 @@ func (s *stubStacksService) StackByName(name string) (*portainer.Stack, error) {
return &stack, nil
}
}
+
return nil, errors.ErrObjectNotFound
}
@@ -440,6 +404,7 @@ func (s *stubStacksService) StacksByName(name string) ([]portainer.Stack, error)
result = append(result, stack)
}
}
+
return result, nil
}
@@ -449,6 +414,7 @@ func (s *stubStacksService) StackByWebhookID(webhookID string) (*portainer.Stack
return &stack, nil
}
}
+
return nil, errors.ErrObjectNotFound
}
From e68bd53e303461ae1249da121e4220806bd17705 Mon Sep 17 00:00:00 2001
From: samdulam
Date: Tue, 25 Mar 2025 08:40:15 +0530
Subject: [PATCH 063/212] Update bug_report template with 2.27.3 (#572)
---
.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 ef5a073fb..76b2e0f69 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -96,6 +96,7 @@ body:
options:
- '2.28.1'
- '2.28.0'
+ - '2.27.3'
- '2.27.2'
- '2.27.1'
- '2.27.0'
From 0ebfe047d1153fadd220cb3c31c31012b93f2084 Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Wed, 26 Mar 2025 11:32:26 +1300
Subject: [PATCH 064/212] feat(helm): use helm upgrade for install [r8s-258]
(#568)
---
api/http/handler/helm/helm_delete_test.go | 2 +-
api/http/handler/helm/helm_install.go | 2 +-
api/http/handler/helm/helm_list_test.go | 2 +-
pkg/libhelm/options/install_options.go | 3 +
pkg/libhelm/sdk/common.go | 87 +++++++++++
pkg/libhelm/sdk/install.go | 106 +++----------
pkg/libhelm/sdk/install_test.go | 88 ++++++-----
pkg/libhelm/sdk/list.go | 6 +-
pkg/libhelm/sdk/release.go | 47 ++++++
pkg/libhelm/sdk/show.go | 10 +-
pkg/libhelm/sdk/show_test.go | 2 +-
pkg/libhelm/sdk/testutils/values.go | 26 ++++
pkg/libhelm/sdk/uninstall.go | 7 +-
pkg/libhelm/sdk/uninstall_test.go | 2 +-
pkg/libhelm/sdk/upgrade.go | 161 +++++++++++++++++++
pkg/libhelm/sdk/upgrade_test.go | 179 ++++++++++++++++++++++
pkg/libhelm/test/mock.go | 5 +
pkg/libhelm/test/values.go | 26 ++++
pkg/libhelm/types/types.go | 2 +-
19 files changed, 613 insertions(+), 150 deletions(-)
create mode 100644 pkg/libhelm/sdk/common.go
create mode 100644 pkg/libhelm/sdk/release.go
create mode 100644 pkg/libhelm/sdk/testutils/values.go
create mode 100644 pkg/libhelm/sdk/upgrade.go
create mode 100644 pkg/libhelm/sdk/upgrade_test.go
create mode 100644 pkg/libhelm/test/values.go
diff --git a/api/http/handler/helm/helm_delete_test.go b/api/http/handler/helm/helm_delete_test.go
index ed77abdd2..3c90c1758 100644
--- a/api/http/handler/helm/helm_delete_test.go
+++ b/api/http/handler/helm/helm_delete_test.go
@@ -42,7 +42,7 @@ func Test_helmDelete(t *testing.T) {
// Install a single chart directly, to be deleted by the handler
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
- h.helmPackageManager.Install(options)
+ h.helmPackageManager.Upgrade(options)
t.Run("helmDelete succeeds with admin user", func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/1/kubernetes/helm/"+options.Name, nil)
diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go
index fffdf21bb..d7254c1f4 100644
--- a/api/http/handler/helm/helm_install.go
+++ b/api/http/handler/helm/helm_install.go
@@ -125,7 +125,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
installOpts.ValuesFile = file.Name()
}
- release, err := handler.helmPackageManager.Install(installOpts)
+ release, err := handler.helmPackageManager.Upgrade(installOpts)
if err != nil {
return nil, err
}
diff --git a/api/http/handler/helm/helm_list_test.go b/api/http/handler/helm/helm_list_test.go
index b8dd40354..9f69fe11d 100644
--- a/api/http/handler/helm/helm_list_test.go
+++ b/api/http/handler/helm/helm_list_test.go
@@ -43,7 +43,7 @@ func Test_helmList(t *testing.T) {
// Install a single chart. We expect to get these values back
options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
- h.helmPackageManager.Install(options)
+ h.helmPackageManager.Upgrade(options)
t.Run("helmList", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil)
diff --git a/pkg/libhelm/options/install_options.go b/pkg/libhelm/options/install_options.go
index 5cc6081fb..943f28041 100644
--- a/pkg/libhelm/options/install_options.go
+++ b/pkg/libhelm/options/install_options.go
@@ -1,5 +1,7 @@
package options
+import "time"
+
type InstallOptions struct {
Name string
Chart string
@@ -8,6 +10,7 @@ type InstallOptions struct {
Wait bool
ValuesFile string
PostRenderer string
+ Timeout time.Duration
KubernetesClusterAccess *KubernetesClusterAccess
// Optional environment vars to pass when running helm
diff --git a/pkg/libhelm/sdk/common.go b/pkg/libhelm/sdk/common.go
new file mode 100644
index 000000000..b1d218cae
--- /dev/null
+++ b/pkg/libhelm/sdk/common.go
@@ -0,0 +1,87 @@
+package sdk
+
+import (
+ "os"
+
+ "github.com/pkg/errors"
+ "github.com/rs/zerolog/log"
+ "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v3/pkg/chart"
+ "helm.sh/helm/v3/pkg/chart/loader"
+ "helm.sh/helm/v3/pkg/downloader"
+ "helm.sh/helm/v3/pkg/getter"
+)
+
+// loadAndValidateChartWithPathOptions locates and loads the chart, and validates it.
+// it also checks for chart dependencies and updates them if necessary.
+// it returns the chart information.
+func (hspm *HelmSDKPackageManager) loadAndValidateChartWithPathOptions(chartPathOptions *action.ChartPathOptions, chartName, repoURL string, dependencyUpdate bool, operation string) (*chart.Chart, error) {
+ // Locate and load the chart
+ chartPathOptions.RepoURL = repoURL
+ chartPath, err := chartPathOptions.LocateChart(chartName, hspm.settings)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart", chartName).
+ Err(err).
+ Msg("Failed to locate chart for helm " + operation)
+ return nil, errors.Wrapf(err, "failed to find the helm chart at the path: %s/%s", repoURL, chartName)
+ }
+
+ chartReq, err := loader.Load(chartPath)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart_path", chartPath).
+ Err(err).
+ Msg("Failed to load chart for helm " + operation)
+ return nil, errors.Wrap(err, "failed to load chart for helm "+operation)
+ }
+
+ // Check chart dependencies to make sure all are present in /charts
+ if chartDependencies := chartReq.Metadata.Dependencies; chartDependencies != nil {
+ if err := action.CheckDependencies(chartReq, chartDependencies); err != nil {
+ err = errors.Wrap(err, "failed to check chart dependencies for helm "+operation)
+ if !dependencyUpdate {
+ return nil, err
+ }
+
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("chart", chartName).
+ Msg("Updating chart dependencies for helm " + operation)
+
+ providers := getter.All(hspm.settings)
+ manager := &downloader.Manager{
+ Out: os.Stdout,
+ ChartPath: chartPath,
+ Keyring: chartPathOptions.Keyring,
+ SkipUpdate: false,
+ Getters: providers,
+ RepositoryConfig: hspm.settings.RepositoryConfig,
+ RepositoryCache: hspm.settings.RepositoryCache,
+ Debug: hspm.settings.Debug,
+ }
+ if err := manager.Update(); err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart", chartName).
+ Err(err).
+ Msg("Failed to update chart dependencies for helm " + operation)
+ return nil, errors.Wrap(err, "failed to update chart dependencies for helm "+operation)
+ }
+
+ // Reload the chart with the updated Chart.lock file.
+ if chartReq, err = loader.Load(chartPath); err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart_path", chartPath).
+ Err(err).
+ Msg("Failed to reload chart after dependency update for helm " + operation)
+ return nil, errors.Wrap(err, "failed to reload chart after dependency update for helm "+operation)
+ }
+ }
+ }
+
+ return chartReq, nil
+}
diff --git a/pkg/libhelm/sdk/install.go b/pkg/libhelm/sdk/install.go
index 26b50927b..29f578806 100644
--- a/pkg/libhelm/sdk/install.go
+++ b/pkg/libhelm/sdk/install.go
@@ -1,22 +1,18 @@
package sdk
import (
- "os"
+ "time"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
"github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
"helm.sh/helm/v3/pkg/action"
- "helm.sh/helm/v3/pkg/chart"
- "helm.sh/helm/v3/pkg/chart/loader"
- "helm.sh/helm/v3/pkg/downloader"
- "helm.sh/helm/v3/pkg/getter"
"helm.sh/helm/v3/pkg/postrender"
)
// Install implements the HelmPackageManager interface by using the Helm SDK to install a chart.
-func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) (*release.Release, error) {
+func (hspm *HelmSDKPackageManager) install(installOpts options.InstallOptions) (*release.Release, error) {
log.Debug().
Str("context", "HelmClient").
Str("chart", installOpts.Chart).
@@ -42,12 +38,7 @@ func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) (
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, installOpts.Namespace, installOpts.KubernetesClusterAccess)
if err != nil {
- log.Error().
- Str("context", "HelmClient").
- Str("chart", installOpts.Chart).
- Str("namespace", installOpts.Namespace).
- Err(err).
- Msg("Failed to initialize helm configuration for helm release installation")
+ // error is already logged in initActionConfig
return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release installation")
}
@@ -69,7 +60,7 @@ func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) (
return nil, errors.Wrap(err, "failed to get Helm values from file for helm release installation")
}
- chart, err := hspm.loadAndValidateChart(installClient, installOpts)
+ chart, err := hspm.loadAndValidateChartWithPathOptions(&installClient.ChartPathOptions, installOpts.Chart, installOpts.Repo, installClient.DependencyUpdate, "release installation")
if err != nil {
log.Error().
Str("context", "HelmClient").
@@ -114,90 +105,29 @@ func (hspm *HelmSDKPackageManager) Install(installOpts options.InstallOptions) (
}, nil
}
-// loadAndValidateChart locates and loads the chart, and validates it.
-// it also checks for chart dependencies and updates them if necessary.
-// it returns the chart information.
-func (hspm *HelmSDKPackageManager) loadAndValidateChart(installClient *action.Install, installOpts options.InstallOptions) (*chart.Chart, error) {
- // Locate and load the chart
- chartPath, err := installClient.ChartPathOptions.LocateChart(installOpts.Chart, hspm.settings)
- if err != nil {
- log.Error().
- Str("context", "HelmClient").
- Str("chart", installOpts.Chart).
- Err(err).
- Msg("Failed to locate chart for helm release installation")
- return nil, errors.Wrapf(err, "failed to find the helm chart at the path: %s/%s", installOpts.Repo, installOpts.Chart)
- }
-
- chartReq, err := loader.Load(chartPath)
- if err != nil {
- log.Error().
- Str("context", "HelmClient").
- Str("chart_path", chartPath).
- Err(err).
- Msg("Failed to load chart for helm release installation")
- return nil, errors.Wrap(err, "failed to load chart for helm release installation")
- }
-
- // Check chart dependencies to make sure all are present in /charts
- if chartDependencies := chartReq.Metadata.Dependencies; chartDependencies != nil {
- if err := action.CheckDependencies(chartReq, chartDependencies); err != nil {
- err = errors.Wrap(err, "failed to check chart dependencies for helm release installation")
- if !installClient.DependencyUpdate {
- return nil, err
- }
-
- log.Debug().
- Str("context", "HelmClient").
- Str("chart", installOpts.Chart).
- Msg("Updating chart dependencies for helm release installation")
-
- providers := getter.All(hspm.settings)
- manager := &downloader.Manager{
- Out: os.Stdout,
- ChartPath: chartPath,
- Keyring: installClient.ChartPathOptions.Keyring,
- SkipUpdate: false,
- Getters: providers,
- RepositoryConfig: hspm.settings.RepositoryConfig,
- RepositoryCache: hspm.settings.RepositoryCache,
- Debug: hspm.settings.Debug,
- }
- if err := manager.Update(); err != nil {
- log.Error().
- Str("context", "HelmClient").
- Str("chart", installOpts.Chart).
- Err(err).
- Msg("Failed to update chart dependencies for helm release installation")
- return nil, errors.Wrap(err, "failed to update chart dependencies for helm release installation")
- }
-
- // Reload the chart with the updated Chart.lock file.
- if chartReq, err = loader.Load(chartPath); err != nil {
- log.Error().
- Str("context", "HelmClient").
- Str("chart_path", chartPath).
- Err(err).
- Msg("Failed to reload chart after dependency update for helm release installation")
- return nil, errors.Wrap(err, "failed to reload chart after dependency update for helm release installation")
- }
- }
- }
-
- return chartReq, nil
-}
-
// initInstallClient initializes the install client with the given options
// and return the install client.
func initInstallClient(actionConfig *action.Configuration, installOpts options.InstallOptions) (*action.Install, error) {
installClient := action.NewInstall(actionConfig)
installClient.CreateNamespace = true
installClient.DependencyUpdate = true
-
installClient.ReleaseName = installOpts.Name
- installClient.Namespace = installOpts.Namespace
installClient.ChartPathOptions.RepoURL = installOpts.Repo
installClient.Wait = installOpts.Wait
+ installClient.Timeout = installOpts.Timeout
+
+ // Set default values if not specified
+ if installOpts.Timeout == 0 {
+ installClient.Timeout = 5 * time.Minute
+ } else {
+ installClient.Timeout = installOpts.Timeout
+ }
+ if installOpts.Namespace == "" {
+ installClient.Namespace = "default"
+ } else {
+ installClient.Namespace = installOpts.Namespace
+ }
+
if installOpts.PostRenderer != "" {
postRenderer, err := postrender.NewExec(installOpts.PostRenderer)
if err != nil {
diff --git a/pkg/libhelm/sdk/install_test.go b/pkg/libhelm/sdk/install_test.go
index 9bd0d04f3..d08ad3441 100644
--- a/pkg/libhelm/sdk/install_test.go
+++ b/pkg/libhelm/sdk/install_test.go
@@ -9,26 +9,6 @@ import (
"github.com/stretchr/testify/assert"
)
-func createValuesFile(values string) (string, error) {
- file, err := os.CreateTemp("", "helm-values")
- if err != nil {
- return "", err
- }
-
- _, err = file.WriteString(values)
- if err != nil {
- file.Close()
- return "", err
- }
-
- err = file.Close()
- if err != nil {
- return "", err
- }
-
- return file.Name(), nil
-}
-
func Test_Install(t *testing.T) {
test.EnsureIntegrationTest(t)
is := assert.New(t)
@@ -44,10 +24,14 @@ func Test_Install(t *testing.T) {
Repo: "https://kubernetes.github.io/ingress-nginx",
}
- release, err := hspm.Install(installOpts)
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ release, err := hspm.Upgrade(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
- Name: "test-nginx",
+ Name: installOpts.Name,
})
}
@@ -60,9 +44,8 @@ func Test_Install(t *testing.T) {
t.Run("successfully installs nginx with values", func(t *testing.T) {
// SDK equivalent of: helm install test-nginx-2 --repo https://kubernetes.github.io/ingress-nginx nginx --values /tmp/helm-values3161785816
- values, err := createValuesFile("service:\n port: 8081")
+ values, err := test.CreateValuesFile("service:\n port: 8081")
is.NoError(err, "should create a values file")
-
defer os.Remove(values)
installOpts := options.InstallOptions{
@@ -71,10 +54,15 @@ func Test_Install(t *testing.T) {
Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values,
}
- release, err := hspm.Install(installOpts)
+
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ release, err := hspm.Upgrade(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
- Name: "test-nginx-2",
+ Name: installOpts.Name,
})
}
@@ -92,7 +80,12 @@ func Test_Install(t *testing.T) {
Chart: "portainer",
Repo: "https://portainer.github.io/k8s/",
}
- release, err := hspm.Install(installOpts)
+
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ release, err := hspm.Upgrade(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: installOpts.Name,
@@ -108,9 +101,8 @@ func Test_Install(t *testing.T) {
t.Run("install with values as string", func(t *testing.T) {
// First create a values file since InstallOptions doesn't support values as string directly
- values, err := createValuesFile("service:\n port: 8082")
+ values, err := test.CreateValuesFile("service:\n port: 8082")
is.NoError(err, "should create a values file")
-
defer os.Remove(values)
// Install with values file
@@ -120,10 +112,15 @@ func Test_Install(t *testing.T) {
Repo: "https://kubernetes.github.io/ingress-nginx",
ValuesFile: values,
}
- release, err := hspm.Install(installOpts)
+
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ release, err := hspm.Upgrade(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
- Name: "test-nginx-3",
+ Name: installOpts.Name,
})
}
@@ -140,11 +137,15 @@ func Test_Install(t *testing.T) {
Repo: "https://kubernetes.github.io/ingress-nginx",
Namespace: "default",
}
- release, err := hspm.Install(installOpts)
+
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ release, err := hspm.Upgrade(installOpts)
if release != nil {
defer hspm.Uninstall(options.UninstallOptions{
- Name: "test-nginx-4",
- Namespace: "default",
+ Name: installOpts.Name,
})
}
@@ -159,10 +160,15 @@ func Test_Install(t *testing.T) {
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
- _, err := hspm.Install(installOpts)
+
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ _, err := hspm.Upgrade(installOpts)
is.Error(err, "should return an error when name is not provided")
- is.Equal(err.Error(), "name is required")
+ // is.Equal(err.Error(), "name is required for helm release installation")
})
t.Run("install with invalid chart", func(t *testing.T) {
@@ -172,9 +178,8 @@ func Test_Install(t *testing.T) {
Chart: "non-existent-chart",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
- _, err := hspm.Install(installOpts)
+ _, err := hspm.Upgrade(installOpts)
is.Error(err, "should return error when chart doesn't exist")
- is.Equal(err.Error(), "failed to find the helm chart at the path: https://kubernetes.github.io/ingress-nginx/non-existent-chart")
})
t.Run("install with invalid repo", func(t *testing.T) {
@@ -184,7 +189,12 @@ func Test_Install(t *testing.T) {
Chart: "nginx",
Repo: "https://non-existent-repo.example.com",
}
- _, err := hspm.Install(installOpts)
+
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ _, err := hspm.Upgrade(installOpts)
is.Error(err, "should return error when repo doesn't exist")
})
}
diff --git a/pkg/libhelm/sdk/list.go b/pkg/libhelm/sdk/list.go
index 494298a91..6e688d1b6 100644
--- a/pkg/libhelm/sdk/list.go
+++ b/pkg/libhelm/sdk/list.go
@@ -26,11 +26,7 @@ func (hspm *HelmSDKPackageManager) List(listOpts options.ListOptions) ([]release
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, listOpts.Namespace, listOpts.KubernetesClusterAccess)
if err != nil {
- log.Error().
- Str("context", "HelmClient").
- Str("namespace", listOpts.Namespace).
- Err(err).
- Msg("Failed to initialize helm configuration")
+ // error is already logged in initActionConfig
return nil, errors.Wrap(err, "failed to initialize helm configuration")
}
diff --git a/pkg/libhelm/sdk/release.go b/pkg/libhelm/sdk/release.go
new file mode 100644
index 000000000..76f6840ff
--- /dev/null
+++ b/pkg/libhelm/sdk/release.go
@@ -0,0 +1,47 @@
+package sdk
+
+import (
+ "fmt"
+
+ "github.com/pkg/errors"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v3/pkg/release"
+ "helm.sh/helm/v3/pkg/storage/driver"
+)
+
+func (hspm *HelmSDKPackageManager) doesReleaseExist(releaseName, namespace string, clusterAccess *options.KubernetesClusterAccess) (bool, error) {
+ // Initialize action configuration
+ actionConfig := new(action.Configuration)
+ err := hspm.initActionConfig(actionConfig, namespace, clusterAccess)
+ if err != nil {
+ // error is already logged in initActionConfig
+ return false, fmt.Errorf("failed to initialize helm configuration: %w", err)
+ }
+
+ historyClient, err := hspm.initHistoryClient(actionConfig, namespace, clusterAccess)
+ if err != nil {
+ // error is already logged in initHistoryClient
+ return false, fmt.Errorf("failed to initialize helm history client: %w", err)
+ }
+
+ versions, err := historyClient.Run(releaseName)
+ if errors.Is(err, driver.ErrReleaseNotFound) || isReleaseUninstalled(versions) {
+ return false, nil
+ } else if err != nil {
+ return false, fmt.Errorf("failed to get history: %w", err)
+ }
+
+ return true, nil
+}
+
+func isReleaseUninstalled(versions []*release.Release) bool {
+ return len(versions) > 0 && versions[len(versions)-1].Info.Status == release.StatusUninstalled
+}
+
+func (hspm *HelmSDKPackageManager) initHistoryClient(actionConfig *action.Configuration, namespace string, clusterAccess *options.KubernetesClusterAccess) (*action.History, error) {
+ historyClient := action.NewHistory(actionConfig)
+ historyClient.Max = 1
+
+ return historyClient, nil
+}
diff --git a/pkg/libhelm/sdk/show.go b/pkg/libhelm/sdk/show.go
index aa674a8c0..45f77719a 100644
--- a/pkg/libhelm/sdk/show.go
+++ b/pkg/libhelm/sdk/show.go
@@ -32,13 +32,11 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
Str("output_format", string(showOpts.OutputFormat)).
Msg("Showing chart information")
- // Initialize action configuration
+ // Initialize action configuration (no namespace or cluster access needed)
actionConfig := new(action.Configuration)
- if err := actionConfig.Init(nil, "", "", func(format string, v ...interface{}) {}); err != nil {
- log.Error().
- Str("context", "HelmClient").
- Err(err).
- Msg("Failed to initialize helm 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)
}
diff --git a/pkg/libhelm/sdk/show_test.go b/pkg/libhelm/sdk/show_test.go
index 124aac02d..302f188ca 100644
--- a/pkg/libhelm/sdk/show_test.go
+++ b/pkg/libhelm/sdk/show_test.go
@@ -21,7 +21,7 @@ func Test_Show(t *testing.T) {
Chart: "ingress-nginx",
Repo: "https://kubernetes.github.io/ingress-nginx",
}
- release, err := hspm.Install(installOpts)
+ release, err := hspm.Upgrade(installOpts)
if release != nil || err != nil {
defer hspm.Uninstall(options.UninstallOptions{
Name: "ingress-nginx",
diff --git a/pkg/libhelm/sdk/testutils/values.go b/pkg/libhelm/sdk/testutils/values.go
new file mode 100644
index 000000000..c1d14c3e7
--- /dev/null
+++ b/pkg/libhelm/sdk/testutils/values.go
@@ -0,0 +1,26 @@
+package testutils
+
+import (
+ "os"
+)
+
+// CreateValuesFile creates a temporary file with the given content for testing
+func CreateValuesFile(values string) (string, error) {
+ file, err := os.CreateTemp("", "helm-values")
+ if err != nil {
+ return "", err
+ }
+
+ _, err = file.WriteString(values)
+ if err != nil {
+ file.Close()
+ return "", err
+ }
+
+ err = file.Close()
+ if err != nil {
+ return "", err
+ }
+
+ return file.Name(), nil
+}
diff --git a/pkg/libhelm/sdk/uninstall.go b/pkg/libhelm/sdk/uninstall.go
index 5459b9e84..c2927999c 100644
--- a/pkg/libhelm/sdk/uninstall.go
+++ b/pkg/libhelm/sdk/uninstall.go
@@ -26,12 +26,7 @@ func (hspm *HelmSDKPackageManager) Uninstall(uninstallOpts options.UninstallOpti
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, uninstallOpts.Namespace, uninstallOpts.KubernetesClusterAccess)
if err != nil {
- log.Error().
- Str("context", "HelmClient").
- Str("release", uninstallOpts.Name).
- Str("namespace", uninstallOpts.Namespace).
- Err(err).
- Msg("Failed to initialize helm configuration")
+ // error is already logged in initActionConfig
return errors.Wrap(err, "failed to initialize helm configuration")
}
diff --git a/pkg/libhelm/sdk/uninstall_test.go b/pkg/libhelm/sdk/uninstall_test.go
index 28ef1cb21..684be1780 100644
--- a/pkg/libhelm/sdk/uninstall_test.go
+++ b/pkg/libhelm/sdk/uninstall_test.go
@@ -48,7 +48,7 @@ func Test_Uninstall(t *testing.T) {
}
// Install the release
- _, err := hspm.Install(installOpts)
+ _, err := hspm.Upgrade(installOpts)
if err != nil {
t.Logf("Error installing release: %v", err)
t.Skip("Skipping uninstall test because install failed")
diff --git a/pkg/libhelm/sdk/upgrade.go b/pkg/libhelm/sdk/upgrade.go
new file mode 100644
index 000000000..cc284cf31
--- /dev/null
+++ b/pkg/libhelm/sdk/upgrade.go
@@ -0,0 +1,161 @@
+package sdk
+
+import (
+ "time"
+
+ "github.com/pkg/errors"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/rs/zerolog/log"
+ "helm.sh/helm/v3/pkg/action"
+ "helm.sh/helm/v3/pkg/postrender"
+)
+
+// Upgrade implements the HelmPackageManager interface by using the Helm SDK to upgrade a chart.
+// If the release does not exist, it will install it instead.
+func (hspm *HelmSDKPackageManager) Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error) {
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("chart", upgradeOpts.Chart).
+ Str("name", upgradeOpts.Name).
+ Str("namespace", upgradeOpts.Namespace).
+ Str("repo", upgradeOpts.Repo).
+ Bool("wait", upgradeOpts.Wait).
+ Msg("Upgrading Helm chart")
+
+ if upgradeOpts.Name == "" {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart", upgradeOpts.Chart).
+ Str("name", upgradeOpts.Name).
+ Str("namespace", upgradeOpts.Namespace).
+ Str("repo", upgradeOpts.Repo).
+ Bool("wait", upgradeOpts.Wait).
+ Msg("Name is required for helm release upgrade")
+ return nil, errors.New("name is required for helm release upgrade")
+ }
+
+ // Check if the release exists
+ exists, err := hspm.doesReleaseExist(upgradeOpts.Name, upgradeOpts.Namespace, upgradeOpts.KubernetesClusterAccess)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("name", upgradeOpts.Name).
+ Str("namespace", upgradeOpts.Namespace).
+ Err(err).
+ Msg("Failed to check if release exists")
+ return nil, errors.Wrap(err, "failed to check if release exists")
+ }
+
+ // If the release doesn't exist, install it instead
+ if !exists {
+ log.Info().
+ Str("context", "HelmClient").
+ Str("chart", upgradeOpts.Chart).
+ Str("name", upgradeOpts.Name).
+ Str("namespace", upgradeOpts.Namespace).
+ Msg("Release doesn't exist, installing instead")
+ return hspm.install(upgradeOpts)
+ }
+
+ // Initialize action configuration with kubernetes config
+ actionConfig := new(action.Configuration)
+ err = hspm.initActionConfig(actionConfig, upgradeOpts.Namespace, upgradeOpts.KubernetesClusterAccess)
+ if err != nil {
+ // error is already logged in initActionConfig
+ return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release upgrade")
+ }
+
+ upgradeClient, err := initUpgradeClient(actionConfig, upgradeOpts)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to initialize helm upgrade client for helm release upgrade")
+ return nil, errors.Wrap(err, "failed to initialize helm upgrade client for helm release upgrade")
+ }
+
+ values, err := hspm.GetHelmValuesFromFile(upgradeOpts.ValuesFile)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to get Helm values from file for helm release upgrade")
+ return nil, errors.Wrap(err, "failed to get Helm values from file for helm release upgrade")
+ }
+
+ chart, err := hspm.loadAndValidateChartWithPathOptions(&upgradeClient.ChartPathOptions, upgradeOpts.Chart, upgradeOpts.Repo, upgradeClient.DependencyUpdate, "release upgrade")
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to load and validate chart for helm release upgrade")
+ return nil, errors.Wrap(err, "failed to load and validate chart for helm release upgrade")
+ }
+
+ log.Info().
+ Str("context", "HelmClient").
+ Str("chart", upgradeOpts.Chart).
+ Str("name", upgradeOpts.Name).
+ Str("namespace", upgradeOpts.Namespace).
+ Msg("Running chart upgrade for helm release")
+
+ helmRelease, err := upgradeClient.Run(upgradeOpts.Name, chart, values)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("chart", upgradeOpts.Chart).
+ Str("name", upgradeOpts.Name).
+ Str("namespace", upgradeOpts.Namespace).
+ Err(err).
+ Msg("Failed to upgrade helm chart for helm release upgrade")
+ return nil, errors.Wrap(err, "helm was not able to upgrade the chart for helm release upgrade")
+ }
+
+ return &release.Release{
+ Name: helmRelease.Name,
+ Namespace: helmRelease.Namespace,
+ Chart: release.Chart{
+ Metadata: &release.Metadata{
+ Name: helmRelease.Chart.Metadata.Name,
+ Version: helmRelease.Chart.Metadata.Version,
+ AppVersion: helmRelease.Chart.Metadata.AppVersion,
+ },
+ },
+ Labels: helmRelease.Labels,
+ Version: helmRelease.Version,
+ Manifest: helmRelease.Manifest,
+ }, nil
+}
+
+// initUpgradeClient initializes the upgrade client with the given options
+// and return the upgrade client.
+func initUpgradeClient(actionConfig *action.Configuration, upgradeOpts options.InstallOptions) (*action.Upgrade, error) {
+ upgradeClient := action.NewUpgrade(actionConfig)
+ upgradeClient.DependencyUpdate = true
+ upgradeClient.Atomic = true
+ upgradeClient.ChartPathOptions.RepoURL = upgradeOpts.Repo
+ upgradeClient.Wait = upgradeOpts.Wait
+
+ // Set default values if not specified
+ if upgradeOpts.Timeout == 0 {
+ upgradeClient.Timeout = 5 * time.Minute
+ } else {
+ upgradeClient.Timeout = upgradeOpts.Timeout
+ }
+ if upgradeOpts.Namespace == "" {
+ upgradeOpts.Namespace = "default"
+ } else {
+ upgradeClient.Namespace = upgradeOpts.Namespace
+ }
+
+ if upgradeOpts.PostRenderer != "" {
+ postRenderer, err := postrender.NewExec(upgradeOpts.PostRenderer)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to create post renderer")
+ }
+ upgradeClient.PostRenderer = postRenderer
+ }
+
+ return upgradeClient, nil
+}
diff --git a/pkg/libhelm/sdk/upgrade_test.go b/pkg/libhelm/sdk/upgrade_test.go
new file mode 100644
index 000000000..86b88d0c5
--- /dev/null
+++ b/pkg/libhelm/sdk/upgrade_test.go
@@ -0,0 +1,179 @@
+package sdk
+
+import (
+ "os"
+ "testing"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/test"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUpgrade(t *testing.T) {
+ test.EnsureIntegrationTest(t)
+ is := assert.New(t)
+
+ // Create a new SDK package manager
+ hspm := NewHelmSDKPackageManager()
+
+ t.Run("when no release exists, the chart should be installed", func(t *testing.T) {
+ // SDK equivalent of: helm upgrade --install test-new-nginx --repo https://kubernetes.github.io/ingress-nginx ingress-nginx
+ upgradeOpts := options.InstallOptions{
+ Name: "test-new-nginx",
+ Namespace: "default",
+ Chart: "ingress-nginx",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ }
+
+ // Ensure the release doesn't exist before test
+ hspm.Uninstall(options.UninstallOptions{
+ Name: upgradeOpts.Name,
+ })
+
+ release, err := hspm.Upgrade(upgradeOpts)
+ if release != nil {
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: upgradeOpts.Name,
+ })
+ }
+
+ is.NoError(err, "should successfully install release via upgrade")
+ is.NotNil(release, "should return non-nil release")
+ is.Equal(upgradeOpts.Name, release.Name, "release name should match")
+ is.Equal(1, release.Version, "release version should be 1 for new install")
+ is.NotEmpty(release.Manifest, "release manifest should not be empty")
+
+ // Cleanup
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: upgradeOpts.Name,
+ })
+ })
+
+ t.Run("when release exists, the chart should be upgraded", func(t *testing.T) {
+ // First install a release
+ installOpts := options.InstallOptions{
+ Name: "test-upgrade-nginx",
+ Chart: "ingress-nginx",
+ Namespace: "default",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ }
+
+ // Ensure the release doesn't exist before test
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ release, err := hspm.Upgrade(installOpts)
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+ is.NoError(err, "should successfully install release")
+ is.NotNil(release, "should return non-nil release")
+
+ // Upgrade the release with the same options
+ upgradedRelease, err := hspm.Upgrade(installOpts)
+
+ is.NoError(err, "should successfully upgrade release")
+ is.NotNil(upgradedRelease, "should return non-nil release")
+ is.Equal("test-upgrade-nginx", upgradedRelease.Name, "release name should match")
+ is.Equal(2, upgradedRelease.Version, "release version should be incremented to 2")
+ is.NotEmpty(upgradedRelease.Manifest, "release manifest should not be empty")
+ })
+
+ t.Run("should be able to upgrade with override values", func(t *testing.T) {
+ // First install a release
+ installOpts := options.InstallOptions{
+ Name: "test-values-nginx",
+ Chart: "ingress-nginx",
+ Namespace: "default",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ }
+
+ // Ensure the release doesn't exist before test
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ release, err := hspm.Upgrade(installOpts) // Cleanup
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+ is.NoError(err, "should successfully install release")
+ is.NotNil(release, "should return non-nil release")
+
+ // Create values file
+ values, err := test.CreateValuesFile("service:\n port: 8083")
+ is.NoError(err, "should create a values file")
+ defer os.Remove(values)
+
+ // Now upgrade with values
+ upgradeOpts := options.InstallOptions{
+ Name: "test-values-nginx",
+ Chart: "ingress-nginx",
+ Namespace: "default",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ ValuesFile: values,
+ }
+
+ upgradedRelease, err := hspm.Upgrade(upgradeOpts)
+
+ is.NoError(err, "should successfully upgrade release with values")
+ is.NotNil(upgradedRelease, "should return non-nil release")
+ is.Equal("test-values-nginx", upgradedRelease.Name, "release name should match")
+ is.Equal(2, upgradedRelease.Version, "release version should be incremented to 2")
+ is.NotEmpty(upgradedRelease.Manifest, "release manifest should not be empty")
+ })
+
+ t.Run("should give an error if the override values are invalid", func(t *testing.T) {
+ // First install a release
+ installOpts := options.InstallOptions{
+ Name: "test-invalid-values",
+ Chart: "ingress-nginx",
+ Namespace: "default",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ }
+
+ // Ensure the release doesn't exist before test
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ release, err := hspm.Upgrade(installOpts)
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+ is.NoError(err, "should successfully install release")
+ is.NotNil(release, "should return non-nil release")
+
+ // Create invalid values file
+ values, err := test.CreateValuesFile("this is not valid yaml")
+ is.NoError(err, "should create a values file")
+ defer os.Remove(values)
+
+ // Now upgrade with invalid values
+ upgradeOpts := options.InstallOptions{
+ Name: "test-invalid-values",
+ Chart: "ingress-nginx",
+ Namespace: "default",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ ValuesFile: values,
+ }
+
+ _, err = hspm.Upgrade(upgradeOpts)
+
+ is.Error(err, "should return error with invalid values")
+ })
+
+ t.Run("should return error when name is not provided", func(t *testing.T) {
+ upgradeOpts := options.InstallOptions{
+ Chart: "ingress-nginx",
+ Namespace: "default",
+ Repo: "https://kubernetes.github.io/ingress-nginx",
+ }
+
+ _, err := hspm.Upgrade(upgradeOpts)
+
+ is.Error(err, "should return an error when name is not provided")
+ is.Equal("name is required for helm release upgrade", err.Error(), "should return correct error message")
+ })
+}
diff --git a/pkg/libhelm/test/mock.go b/pkg/libhelm/test/mock.go
index 0b7712aab..58d53715c 100644
--- a/pkg/libhelm/test/mock.go
+++ b/pkg/libhelm/test/mock.go
@@ -73,6 +73,11 @@ func (hpm *helmMockPackageManager) Install(installOpts options.InstallOptions) (
return newMockRelease(releaseElement), nil
}
+// Upgrade a helm chart (not thread safe)
+func (hpm *helmMockPackageManager) Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error) {
+ return hpm.Install(upgradeOpts)
+}
+
// Show values/readme/chart etc
func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
switch showOpts.OutputFormat {
diff --git a/pkg/libhelm/test/values.go b/pkg/libhelm/test/values.go
new file mode 100644
index 000000000..a3f5df44c
--- /dev/null
+++ b/pkg/libhelm/test/values.go
@@ -0,0 +1,26 @@
+package test
+
+import (
+ "os"
+)
+
+// CreateValuesFile creates a temporary file with the given content for testing
+func CreateValuesFile(values string) (string, error) {
+ file, err := os.CreateTemp("", "helm-values")
+ if err != nil {
+ return "", err
+ }
+
+ _, err = file.WriteString(values)
+ if err != nil {
+ file.Close()
+ return "", err
+ }
+
+ err = file.Close()
+ if err != nil {
+ return "", err
+ }
+
+ return file.Name(), nil
+}
diff --git a/pkg/libhelm/types/types.go b/pkg/libhelm/types/types.go
index 91b777270..8c0caa80b 100644
--- a/pkg/libhelm/types/types.go
+++ b/pkg/libhelm/types/types.go
@@ -12,7 +12,7 @@ type HelmPackageManager interface {
Show(showOpts options.ShowOptions) ([]byte, error)
SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error)
List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
- Install(installOpts options.InstallOptions) (*release.Release, error)
+ Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error)
Uninstall(uninstallOpts options.UninstallOptions) error
}
From 81c5f4acc35f1dde2cf6518befe47b55089a3f66 Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Thu, 27 Mar 2025 17:11:55 +1300
Subject: [PATCH 065/212] feat(editor): provide yaml validation for docker
compose in the portainer web editor [BE-11697] (#526)
---
.../components/code-editor/code-editor.html | 1 +
.../components/code-editor/code-editor.js | 1 +
.../form-components/web-editor-form/index.js | 1 +
.../web-editor-form/web-editor-form.html | 1 +
app/portainer/react/components/index.ts | 1 +
.../stacks/create/createStackController.js | 7 +
.../views/stacks/create/createstack.html | 1 +
app/portainer/views/stacks/edit/stack.html | 6 +-
.../views/stacks/edit/stackController.js | 7 +
app/react/components/CodeEditor.module.css | 24 +
app/react/components/CodeEditor.test.tsx | 115 ++
app/react/components/CodeEditor.tsx | 90 +-
app/react/components/WebEditorForm.tsx | 9 +-
.../CreateView/DockerContentField.tsx | 7 +-
.../CreateView/tests/app-templates.test.tsx | 3 +-
.../tests/custom-templates.test.tsx | 3 +-
.../CreateView/tests/utils.test.tsx | 8 +-
.../EditEdgeStackForm/ComposeForm.tsx | 4 +
.../docker-compose-schema.ts | 1100 +++++++++++++++++
.../useDockerComposeSchema.ts | 37 +
app/setup-tests/mock-codemirror.tsx | 16 +
app/setup-tests/setup-codemirror.ts | 42 +
package.json | 5 +-
tsconfig.json | 4 +-
vitest.config.mts | 10 +-
webpack/webpack.common.js | 7 +
yarn.lock | 572 ++++++++-
27 files changed, 2046 insertions(+), 36 deletions(-)
create mode 100644 app/react/components/CodeEditor.test.tsx
create mode 100644 app/react/hooks/useDockerComposeSchema/docker-compose-schema.ts
create mode 100644 app/react/hooks/useDockerComposeSchema/useDockerComposeSchema.ts
create mode 100644 app/setup-tests/mock-codemirror.tsx
create mode 100644 app/setup-tests/setup-codemirror.ts
diff --git a/app/portainer/components/code-editor/code-editor.html b/app/portainer/components/code-editor/code-editor.html
index f7ad81fc4..2cf42b272 100644
--- a/app/portainer/components/code-editor/code-editor.html
+++ b/app/portainer/components/code-editor/code-editor.html
@@ -6,4 +6,5 @@
on-change="($ctrl.handleChange)"
value="$ctrl.value"
height="$ctrl.height || undefined"
+ schema="$ctrl.schema"
>
diff --git a/app/portainer/components/code-editor/code-editor.js b/app/portainer/components/code-editor/code-editor.js
index c20f6e6fb..db6a4fbaf 100644
--- a/app/portainer/components/code-editor/code-editor.js
+++ b/app/portainer/components/code-editor/code-editor.js
@@ -13,5 +13,6 @@ angular.module('portainer.app').component('codeEditor', {
onChange: '<',
value: '<',
height: '@',
+ schema: '<',
},
});
diff --git a/app/portainer/components/form-components/web-editor-form/index.js b/app/portainer/components/form-components/web-editor-form/index.js
index 5fa476e61..45301f683 100644
--- a/app/portainer/components/form-components/web-editor-form/index.js
+++ b/app/portainer/components/form-components/web-editor-form/index.js
@@ -13,6 +13,7 @@ export const webEditorForm = {
onChange: '<',
hideTitle: '<',
height: '@',
+ schema: '<',
},
transclude: {
diff --git a/app/portainer/components/form-components/web-editor-form/web-editor-form.html b/app/portainer/components/form-components/web-editor-form/web-editor-form.html
index 9dd875abf..1727cc893 100644
--- a/app/portainer/components/form-components/web-editor-form/web-editor-form.html
+++ b/app/portainer/components/form-components/web-editor-form/web-editor-form.html
@@ -48,6 +48,7 @@
value="$ctrl.value"
on-change="($ctrl.onChange)"
height="{{ $ctrl.height }}"
+ schema="$ctrl.schema"
>
diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts
index db071b5f5..535032fae 100644
--- a/app/portainer/react/components/index.ts
+++ b/app/portainer/react/components/index.ts
@@ -232,6 +232,7 @@ export const ngModule = angular
'data-cy',
'versions',
'onVersionChange',
+ 'schema',
])
)
.component(
diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js
index a07c4aa3f..118e2d23a 100644
--- a/app/portainer/views/stacks/create/createStackController.js
+++ b/app/portainer/views/stacks/create/createStackController.js
@@ -10,6 +10,7 @@ import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
+import { getDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
angular
.module('portainer.app')
@@ -351,6 +352,12 @@ angular
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve Containers');
}
+
+ try {
+ $scope.dockerComposeSchema = await getDockerComposeSchema();
+ } catch (err) {
+ Notifications.error('Failure', err, 'Unable to load schema validation for editor');
+ }
}
this.uiCanExit = async function () {
diff --git a/app/portainer/views/stacks/create/createstack.html b/app/portainer/views/stacks/create/createstack.html
index 4674c6e3a..406686f8a 100644
--- a/app/portainer/views/stacks/create/createstack.html
+++ b/app/portainer/views/stacks/create/createstack.html
@@ -130,6 +130,7 @@
yml="true"
placeholder="Define or paste the content of your docker compose file here"
read-only="state.isEditorReadOnly"
+ schema="dockerComposeSchema"
>
diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html
index caf7a4f92..14c5c8628 100644
--- a/app/portainer/views/stacks/edit/stack.html
+++ b/app/portainer/views/stacks/edit/stack.html
@@ -150,8 +150,9 @@
You can get more information about Compose file format in the official documentation .
-
-
{{ state.yamlError }}
+
+
+ {{ state.yamlError || ' ' }}
@@ -163,6 +164,7 @@
yml="true"
on-change="(editorUpdate)"
value="stackFileContent"
+ schema="dockerComposeSchema"
>
diff --git a/app/portainer/views/stacks/edit/stackController.js b/app/portainer/views/stacks/edit/stackController.js
index 385b7101f..8e3db07ec 100644
--- a/app/portainer/views/stacks/edit/stackController.js
+++ b/app/portainer/views/stacks/edit/stackController.js
@@ -8,6 +8,7 @@ import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-u
import { confirm, confirmDelete, confirmWebEditorDiscard } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
+import { getDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
angular.module('portainer.app').controller('StackController', [
'$async',
@@ -491,6 +492,12 @@ angular.module('portainer.app').controller('StackController', [
}
$scope.composeSyntaxMaxVersion = endpoint.ComposeSyntaxMaxVersion;
+
+ try {
+ $scope.dockerComposeSchema = await getDockerComposeSchema();
+ } catch (err) {
+ Notifications.error('Failure', err, 'Unable to load schema validation for editor');
+ }
}
initView();
diff --git a/app/react/components/CodeEditor.module.css b/app/react/components/CodeEditor.module.css
index d7ff66ce2..4936a56e4 100644
--- a/app/react/components/CodeEditor.module.css
+++ b/app/react/components/CodeEditor.module.css
@@ -11,6 +11,8 @@
--bg-codemirror-gutters-color: var(--grey-17);
--bg-codemirror-selected-color: var(--grey-22);
--border-codemirror-cursor-color: var(--black-color);
+ --bg-tooltip-color: var(--white-color);
+ --text-tooltip-color: var(--black-color);
}
:global([theme='dark']) .root {
@@ -24,6 +26,8 @@
--bg-codemirror-gutters-color: var(--grey-3);
--bg-codemirror-selected-color: var(--grey-3);
--border-codemirror-cursor-color: var(--white-color);
+ --bg-tooltip-color: var(--grey-3);
+ --text-tooltip-color: var(--white-color);
}
:global([theme='highcontrast']) .root {
@@ -37,6 +41,8 @@
--bg-codemirror-gutters-color: var(--ui-gray-warm-11);
--bg-codemirror-selected-color: var(--grey-3);
--border-codemirror-cursor-color: var(--white-color);
+ --bg-tooltip-color: var(--black-color);
+ --text-tooltip-color: var(--white-color);
}
.root :global(.cm-editor .cm-gutters) {
@@ -138,3 +144,21 @@
.root :global(.cm-panel.cm-search label) {
@apply text-xs;
}
+
+/* Tooltip styles for all themes */
+.root :global(.cm-tooltip) {
+ @apply bg-white border border-solid border-gray-5 shadow-md text-xs rounded h-min;
+ @apply th-dark:bg-gray-9 th-dark:border-gray-7 th-dark:text-white;
+ @apply th-highcontrast:bg-black th-highcontrast:border-gray-7 th-highcontrast:text-white;
+}
+
+/* Hide the completionInfo tooltip when it's empty */
+/* note: I only chose the complicated selector because the simple selector `.cm-tooltip.cm-completionInfo:empty` didn't work */
+.root :global(.cm-tooltip.cm-completionInfo:not(:has(*:not(:empty)))) {
+ display: none;
+}
+
+/* Active line gutter styles for all themes */
+.root :global(.cm-activeLineGutter) {
+ @apply bg-inherit;
+}
diff --git a/app/react/components/CodeEditor.test.tsx b/app/react/components/CodeEditor.test.tsx
new file mode 100644
index 000000000..1d1a3ae85
--- /dev/null
+++ b/app/react/components/CodeEditor.test.tsx
@@ -0,0 +1,115 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { CodeEditor } from './CodeEditor';
+
+vi.mock('yaml-schema', () => ({}));
+
+const defaultProps = {
+ id: 'test-editor',
+ onChange: vi.fn(),
+ value: '',
+ 'data-cy': 'test-editor',
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+test('should render with basic props', () => {
+ render( );
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+});
+
+test('should display placeholder when provided', async () => {
+ const placeholder = 'Enter your code here';
+ const { findByText } = render(
+
+ );
+
+ const placeholderText = await findByText(placeholder);
+ expect(placeholderText).toBeVisible();
+});
+
+test('should show copy button and copy content', async () => {
+ const testValue = 'test content';
+ const { findByText } = render(
+
+ );
+
+ const mockClipboard = {
+ writeText: vi.fn(),
+ };
+ Object.assign(navigator, {
+ clipboard: mockClipboard,
+ });
+
+ const copyButton = await findByText('Copy to clipboard');
+ expect(copyButton).toBeVisible();
+
+ await userEvent.click(copyButton);
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(testValue);
+});
+
+test('should handle read-only mode', async () => {
+ const { findByRole } = render( );
+ const editor = await findByRole('textbox');
+ // the editor should not editable
+ await userEvent.type(editor, 'test');
+ expect(editor).not.toHaveValue('test');
+});
+
+test('should show version selector when versions are provided', async () => {
+ const versions = [1, 2, 3];
+ const onVersionChange = vi.fn();
+ const { findByRole } = render(
+
+ );
+
+ const selector = await findByRole('combobox');
+ expect(selector).toBeVisible();
+});
+
+test('should handle YAML indentation correctly', async () => {
+ const onChange = vi.fn();
+ const yamlContent = 'services:';
+
+ const { findByRole } = render(
+
+ );
+
+ const editor = await findByRole('textbox');
+ await userEvent.type(editor, '{enter}');
+ await userEvent.keyboard('database:');
+ await userEvent.keyboard('{enter}');
+ await userEvent.keyboard('image: nginx');
+ await userEvent.keyboard('{enter}');
+ await userEvent.keyboard('name: database');
+
+ // Wait for the debounced onChange to be called
+ setTimeout(() => {
+ expect(onChange).toHaveBeenCalledWith(
+ 'services:\n database:\n image: nginx\n name: database'
+ );
+ // debounce timeout is 300ms, so 500ms is enough
+ }, 500);
+});
+
+test('should apply custom height', async () => {
+ const customHeight = '300px';
+ const { findByRole } = render(
+
+ );
+
+ const editor = (await findByRole('textbox')).parentElement?.parentElement;
+ expect(editor).toHaveStyle({ height: customHeight });
+});
diff --git a/app/react/components/CodeEditor.tsx b/app/react/components/CodeEditor.tsx
index 5850fae87..206f7fc5b 100644
--- a/app/react/components/CodeEditor.tsx
+++ b/app/react/components/CodeEditor.tsx
@@ -1,11 +1,24 @@
-import CodeMirror from '@uiw/react-codemirror';
-import { StreamLanguage, LanguageSupport } from '@codemirror/language';
+import CodeMirror, {
+ keymap,
+ oneDarkHighlightStyle,
+} from '@uiw/react-codemirror';
+import {
+ StreamLanguage,
+ LanguageSupport,
+ syntaxHighlighting,
+ indentService,
+} from '@codemirror/language';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { useCallback, useMemo, useState } from 'react';
import { createTheme } from '@uiw/codemirror-themes';
import { tags as highlightTags } from '@lezer/highlight';
+import type { JSONSchema7 } from 'json-schema';
+import { lintKeymap, lintGutter } from '@codemirror/lint';
+import { defaultKeymap } from '@codemirror/commands';
+import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
+import { yamlCompletion, yamlSchema } from 'yaml-schema';
import { AutomationTestingProps } from '@/types';
@@ -28,6 +41,7 @@ interface Props extends AutomationTestingProps {
height?: string;
versions?: number[];
onVersionChange?: (version: number) => void;
+ schema?: JSONSchema7;
}
const theme = createTheme({
@@ -57,18 +71,69 @@ const theme = createTheme({
],
});
-const yamlLanguage = new LanguageSupport(StreamLanguage.define(yaml));
+// Custom indentation service for YAML
+const yamlIndentExtension = indentService.of((context, pos) => {
+ const prevLine = context.lineAt(pos, -1);
+
+ // Default to same as previous line
+ const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
+
+ // If previous line ends with a colon, increase indent
+ if (/:\s*$/.test(prevLine.text)) {
+ return prevIndent + 2; // Indent 2 spaces after a colon
+ }
+
+ return prevIndent;
+});
+
+// Create enhanced YAML language with custom indentation (from @codemirror/legacy-modes/mode/yaml)
+const yamlLanguageLegacy = new LanguageSupport(StreamLanguage.define(yaml), [
+ yamlIndentExtension,
+ syntaxHighlighting(oneDarkHighlightStyle),
+]);
+
const dockerFileLanguage = new LanguageSupport(
StreamLanguage.define(dockerFile)
);
const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
const docTypeExtensionMap: Record = {
- yaml: yamlLanguage,
+ yaml: yamlLanguageLegacy,
dockerfile: dockerFileLanguage,
shell: shellLanguage,
};
+function schemaValidationExtensions(schema: JSONSchema7) {
+ // skip the hover extension because fields like 'networks' display as 'null' with no description when using the default hover
+ // skip the completion extension in favor of custom completion
+ const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
+ return [
+ yaml,
+ linter,
+ autocompletion({
+ icons: false,
+ activateOnTypingDelay: 300,
+ selectOnOpen: true,
+ activateOnTyping: true,
+ override: [
+ (ctx) => {
+ const getCompletions = yamlCompletion();
+ const completions = getCompletions(ctx);
+ if (Array.isArray(completions)) {
+ return null;
+ }
+ return completions;
+ },
+ ],
+ }),
+ stateExtensions,
+ yamlIndentExtension,
+ syntaxHighlighting(oneDarkHighlightStyle),
+ lintGutter(),
+ keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
+ ];
+}
+
export function CodeEditor({
id,
onChange,
@@ -79,17 +144,22 @@ export function CodeEditor({
onVersionChange,
height = '500px',
type,
+ schema,
'data-cy': dataCy,
}: Props) {
const [isRollback, setIsRollback] = useState(false);
const extensions = useMemo(() => {
- const extensions = [];
- if (type && docTypeExtensionMap[type]) {
- extensions.push(docTypeExtensionMap[type]);
+ if (!type || !docTypeExtensionMap[type]) {
+ return [];
}
- return extensions;
- }, [type]);
+ // YAML-specific schema validation
+ if (schema && type === 'yaml') {
+ return schemaValidationExtensions(schema);
+ }
+ // Default language support
+ return [docTypeExtensionMap[type]];
+ }, [type, schema]);
const handleVersionChange = useCallback(
(version: number) => {
@@ -146,7 +216,7 @@ export function CodeEditor({
height={height}
basicSetup={{
highlightSelectionMatches: false,
- autocompletion: false,
+ autocompletion: !!schema,
}}
data-cy={dataCy}
/>
diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx
index b9b95d4fd..222a4552f 100644
--- a/app/react/components/WebEditorForm.tsx
+++ b/app/react/components/WebEditorForm.tsx
@@ -1,11 +1,12 @@
import {
+ ReactNode,
ComponentProps,
PropsWithChildren,
- ReactNode,
- useEffect,
useMemo,
+ useEffect,
} from 'react';
import { useTransitionHook } from '@uirouter/react';
+import { JSONSchema7 } from 'json-schema';
import { BROWSER_OS_PLATFORM } from '@/react/constants';
@@ -63,6 +64,7 @@ interface Props extends CodeEditorProps {
titleContent?: ReactNode;
hideTitle?: boolean;
error?: string;
+ schema?: JSONSchema7;
}
export function WebEditorForm({
@@ -71,6 +73,7 @@ export function WebEditorForm({
hideTitle,
children,
error,
+ schema,
...props
}: PropsWithChildren) {
return (
@@ -94,6 +97,8 @@ export function WebEditorForm({
diff --git a/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx b/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx
index 015efcce3..2f24354bb 100644
--- a/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx
+++ b/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx
@@ -1,3 +1,5 @@
+import { useDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
+
import { InlineLoader } from '@@/InlineLoader';
import { WebEditorForm } from '@@/WebEditorForm';
@@ -14,7 +16,9 @@ export function DockerContentField({
readonly?: boolean;
isLoading?: boolean;
}) {
- if (isLoading) {
+ const dockerComposeSchemaQuery = useDockerComposeSchema();
+
+ if (isLoading || dockerComposeSchemaQuery.isInitialLoading) {
return
Loading stack content... ;
}
@@ -27,6 +31,7 @@ export function DockerContentField({
placeholder="Define or paste the content of your docker compose file here"
error={error}
readonly={readonly}
+ schema={dockerComposeSchemaQuery.data}
data-cy="stack-creation-editor"
>
You can get more information about Compose file format in the{' '}
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 ac597e01a..9cb4ba546 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
@@ -4,8 +4,9 @@ import userEvent from '@testing-library/user-event';
import { http, server } from '@/setup-tests/server';
import selectEvent from '@/react/test-utils/react-select';
+import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
-import { mockCodeMirror, renderCreateForm } from './utils.test';
+import { renderCreateForm } from './utils.test';
// keep mockTemplateId and mockTemplateType in module scope
let mockTemplateId: number;
diff --git a/app/react/edge/edge-stacks/CreateView/tests/custom-templates.test.tsx b/app/react/edge/edge-stacks/CreateView/tests/custom-templates.test.tsx
index e87cba38f..eaece356a 100644
--- a/app/react/edge/edge-stacks/CreateView/tests/custom-templates.test.tsx
+++ b/app/react/edge/edge-stacks/CreateView/tests/custom-templates.test.tsx
@@ -4,8 +4,9 @@ import userEvent from '@testing-library/user-event';
import { http, server } from '@/setup-tests/server';
import selectEvent from '@/react/test-utils/react-select';
+import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
-import { mockCodeMirror, renderCreateForm } from './utils.test';
+import { renderCreateForm } from './utils.test';
// keep mockTemplateId and mockTemplateType in module scope
let mockTemplateId: number;
diff --git a/app/react/edge/edge-stacks/CreateView/tests/utils.test.tsx b/app/react/edge/edge-stacks/CreateView/tests/utils.test.tsx
index 179f67787..6af7868cd 100644
--- a/app/react/edge/edge-stacks/CreateView/tests/utils.test.tsx
+++ b/app/react/edge/edge-stacks/CreateView/tests/utils.test.tsx
@@ -6,6 +6,7 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { http, server } from '@/setup-tests/server';
+import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
import { CreateForm } from '../CreateForm';
@@ -211,13 +212,6 @@ test('The form should render', async () => {
});
});
-export function mockCodeMirror() {
- vi.mock('@uiw/react-codemirror', () => ({
- __esModule: true,
- default: () =>
,
- }));
-}
-
export function renderCreateForm() {
// user declaration needs to go at the start for user id related requests (e.g. git credentials)
const user = new UserViewModel({ Username: 'user' });
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx
index 118de09ac..806d793ab 100644
--- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx
@@ -1,5 +1,7 @@
import { useFormikContext } from 'formik';
+import { useDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
+
import { TextTip } from '@@/Tip/TextTip';
import { WebEditorForm } from '@@/WebEditorForm';
@@ -19,6 +21,7 @@ export function ComposeForm({
versionOptions: number[] | undefined;
}) {
const { errors, values } = useFormikContext
();
+ const { data: dockerComposeSchema } = useDockerComposeSchema();
return (
<>
@@ -61,6 +64,7 @@ export function ComposeForm({
data-cy="compose-editor"
value={values.content}
type="yaml"
+ schema={dockerComposeSchema}
id="compose-editor"
placeholder="Define or paste the content of your docker compose file here"
onChange={(value) => handleContentChange(DeploymentType.Compose, value)}
diff --git a/app/react/hooks/useDockerComposeSchema/docker-compose-schema.ts b/app/react/hooks/useDockerComposeSchema/docker-compose-schema.ts
new file mode 100644
index 000000000..6440d8f9b
--- /dev/null
+++ b/app/react/hooks/useDockerComposeSchema/docker-compose-schema.ts
@@ -0,0 +1,1100 @@
+// based on https://github.com/compose-spec/compose-spec/blob/master/schema/compose-spec.json
+// with added descriptions from https://github.com/microsoft/compose-language-service/blob/7a74283ddb866988fed86241f461a657f0aee6d0/src/service/providers/KeyHoverProvider.ts#L57-L184
+// hopefully https://github.com/compose-spec/compose-spec/pull/581 will apply these same changes
+export const dockerComposeSchema = {
+ $schema: 'https://json-schema.org/draft-07/schema',
+ $id: 'compose_spec.json',
+ type: 'object',
+ title: 'Compose Specification',
+ description:
+ 'The Compose file is a YAML file defining a multi-containers based application.',
+
+ properties: {
+ version: {
+ type: 'string',
+ description:
+ 'The version of the Docker Compose document. Declared for backward compatibility, ignored in recent versions.',
+ },
+
+ name: {
+ type: 'string',
+ description:
+ 'Define the Compose project name, until user defines one explicitly.',
+ },
+
+ include: {
+ type: 'array',
+ items: {
+ $ref: '#/definitions/include',
+ },
+ description: 'Compose sub-projects to be included.',
+ },
+
+ services: {
+ type: 'object',
+ description: 'The services in your project.',
+ patternProperties: {
+ '^[a-zA-Z0-9._-]+$': {
+ $ref: '#/definitions/service',
+ },
+ },
+ additionalProperties: false,
+ },
+
+ networks: {
+ type: 'object',
+ description: 'Networks that are shared among multiple services.',
+ patternProperties: {
+ '^[a-zA-Z0-9._-]+$': {
+ $ref: '#/definitions/network',
+ },
+ },
+ },
+
+ volumes: {
+ type: 'object',
+ description: 'Named volumes that are shared among multiple services.',
+ patternProperties: {
+ '^[a-zA-Z0-9._-]+$': {
+ $ref: '#/definitions/volume',
+ },
+ },
+ additionalProperties: false,
+ },
+
+ secrets: {
+ type: 'object',
+ description: 'Secrets that are shared among multiple services.',
+ patternProperties: {
+ '^[a-zA-Z0-9._-]+$': {
+ $ref: '#/definitions/secret',
+ },
+ },
+ additionalProperties: false,
+ },
+
+ configs: {
+ type: 'object',
+ description: 'Configurations for services in the project.',
+ patternProperties: {
+ '^[a-zA-Z0-9._-]+$': {
+ $ref: '#/definitions/config',
+ },
+ },
+ additionalProperties: false,
+ },
+ },
+
+ patternProperties: { '^x-': {} },
+ additionalProperties: false,
+
+ definitions: {
+ service: {
+ type: 'object',
+
+ properties: {
+ develop: { $ref: '#/definitions/development' },
+ deploy: { $ref: '#/definitions/deployment' },
+ annotations: { $ref: '#/definitions/list_or_dict' },
+ attach: { type: ['boolean', 'string'] },
+ build: {
+ oneOf: [
+ { type: 'string' },
+ {
+ type: 'object',
+ description: 'The context used for building the image.',
+ properties: {
+ context: {
+ type: 'string',
+ description: 'The context used for building the image.',
+ },
+ dockerfile: {
+ type: 'string',
+ description: 'The Dockerfile used for building the image.',
+ },
+ dockerfile_inline: { type: 'string' },
+ entitlements: { type: 'array', items: { type: 'string' } },
+ args: {
+ $ref: '#/definitions/list_or_dict',
+ description: 'Arguments used during the image build process.',
+ },
+ ssh: { $ref: '#/definitions/list_or_dict' },
+ labels: { $ref: '#/definitions/list_or_dict' },
+ cache_from: { type: 'array', items: { type: 'string' } },
+ cache_to: { type: 'array', items: { type: 'string' } },
+ no_cache: { type: ['boolean', 'string'] },
+ additional_contexts: { $ref: '#/definitions/list_or_dict' },
+ network: { type: 'string' },
+ pull: { type: ['boolean', 'string'] },
+ target: { type: 'string' },
+ shm_size: { type: ['integer', 'string'] },
+ extra_hosts: { $ref: '#/definitions/extra_hosts' },
+ isolation: { type: 'string' },
+ privileged: { type: ['boolean', 'string'] },
+ secrets: { $ref: '#/definitions/service_config_or_secret' },
+ tags: { type: 'array', items: { type: 'string' } },
+ ulimits: { $ref: '#/definitions/ulimits' },
+ platforms: { type: 'array', items: { type: 'string' } },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ ],
+ },
+ blkio_config: {
+ type: 'object',
+ properties: {
+ device_read_bps: {
+ type: 'array',
+ items: { $ref: '#/definitions/blkio_limit' },
+ },
+ device_read_iops: {
+ type: 'array',
+ items: { $ref: '#/definitions/blkio_limit' },
+ },
+ device_write_bps: {
+ type: 'array',
+ items: { $ref: '#/definitions/blkio_limit' },
+ },
+ device_write_iops: {
+ type: 'array',
+ items: { $ref: '#/definitions/blkio_limit' },
+ },
+ weight: { type: ['integer', 'string'] },
+ weight_device: {
+ type: 'array',
+ items: { $ref: '#/definitions/blkio_weight' },
+ },
+ },
+ additionalProperties: false,
+ },
+ cap_add: {
+ type: 'array',
+ items: { type: 'string' },
+ uniqueItems: true,
+ },
+ cap_drop: {
+ type: 'array',
+ items: { type: 'string' },
+ uniqueItems: true,
+ },
+ cgroup: { type: 'string', enum: ['host', 'private'] },
+ cgroup_parent: { type: 'string' },
+ command: {
+ $ref: '#/definitions/command',
+ description: 'The command that will be run in the container.',
+ },
+ configs: {
+ $ref: '#/definitions/service_config_or_secret',
+ description: 'Configurations the service will have access to.',
+ },
+ container_name: {
+ type: 'string',
+ description: 'The name that will be given to the container.',
+ },
+ cpu_count: {
+ oneOf: [{ type: 'string' }, { type: 'integer', minimum: 0 }],
+ },
+ cpu_percent: {
+ oneOf: [
+ { type: 'string' },
+ { type: 'integer', minimum: 0, maximum: 100 },
+ ],
+ },
+ cpu_shares: { type: ['number', 'string'] },
+ cpu_quota: { type: ['number', 'string'] },
+ cpu_period: { type: ['number', 'string'] },
+ cpu_rt_period: { type: ['number', 'string'] },
+ cpu_rt_runtime: { type: ['number', 'string'] },
+ cpus: { type: ['number', 'string'] },
+ cpuset: { type: 'string' },
+ credential_spec: {
+ type: 'object',
+ properties: {
+ config: { type: 'string' },
+ file: { type: 'string' },
+ registry: { type: 'string' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ depends_on: {
+ oneOf: [
+ { $ref: '#/definitions/list_of_strings' },
+ {
+ type: 'object',
+ additionalProperties: false,
+ patternProperties: {
+ '^[a-zA-Z0-9._-]+$': {
+ type: 'object',
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ properties: {
+ restart: { type: ['boolean', 'string'] },
+ required: {
+ type: 'boolean',
+ default: true,
+ },
+ condition: {
+ type: 'string',
+ enum: [
+ 'service_started',
+ 'service_healthy',
+ 'service_completed_successfully',
+ ],
+ },
+ },
+ required: ['condition'],
+ },
+ },
+ },
+ ],
+ description:
+ 'Other services that this service depends on, which will be started before this one.',
+ },
+ device_cgroup_rules: { $ref: '#/definitions/list_of_strings' },
+ devices: {
+ type: 'array',
+ items: {
+ oneOf: [
+ { type: 'string' },
+ {
+ type: 'object',
+ required: ['source'],
+ properties: {
+ source: { type: 'string' },
+ target: { type: 'string' },
+ permissions: { type: 'string' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ ],
+ },
+ },
+ dns: { $ref: '#/definitions/string_or_list' },
+ dns_opt: {
+ type: 'array',
+ items: { type: 'string' },
+ uniqueItems: true,
+ },
+ dns_search: { $ref: '#/definitions/string_or_list' },
+ domainname: { type: 'string' },
+ entrypoint: {
+ $ref: '#/definitions/command',
+ description: 'The entrypoint to the application in the container.',
+ },
+ env_file: {
+ $ref: '#/definitions/env_file',
+ description:
+ 'Files containing environment variables that will be included.',
+ },
+ label_file: { $ref: '#/definitions/label_file' },
+ environment: {
+ $ref: '#/definitions/list_or_dict',
+ description: 'Environment variables that will be included.',
+ },
+
+ expose: {
+ type: 'array',
+ items: {
+ type: ['string', 'number'],
+ },
+ uniqueItems: true,
+ description:
+ 'Ports exposed to the other services but not to the host machine.',
+ },
+ extends: {
+ oneOf: [
+ { type: 'string' },
+ {
+ type: 'object',
+
+ properties: {
+ service: { type: 'string' },
+ file: { type: 'string' },
+ },
+ required: ['service'],
+ additionalProperties: false,
+ },
+ ],
+ },
+ external_links: {
+ type: 'array',
+ items: { type: 'string' },
+ uniqueItems: true,
+ },
+ extra_hosts: { $ref: '#/definitions/extra_hosts' },
+ gpus: { $ref: '#/definitions/gpus' },
+ group_add: {
+ type: 'array',
+ items: {
+ type: ['string', 'number'],
+ },
+ uniqueItems: true,
+ },
+ healthcheck: {
+ $ref: '#/definitions/healthcheck',
+ description: 'A command for checking if the container is healthy.',
+ },
+ hostname: { type: 'string' },
+ image: {
+ type: 'string',
+ description:
+ 'The image that will be pulled for the service. If `build` is specified, the built image will be given this tag.',
+ },
+ init: { type: ['boolean', 'string'] },
+ ipc: { type: 'string' },
+ isolation: { type: 'string' },
+ labels: {
+ $ref: '#/definitions/list_or_dict',
+ description: 'Labels that will be given to the container.',
+ },
+ links: { type: 'array', items: { type: 'string' }, uniqueItems: true },
+ logging: {
+ type: 'object',
+ description: 'Settings for logging for this service.',
+ properties: {
+ driver: { type: 'string' },
+ options: {
+ type: 'object',
+ patternProperties: {
+ '^.+$': { type: ['string', 'number', 'null'] },
+ },
+ },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ mac_address: { type: 'string' },
+ mem_limit: { type: ['number', 'string'] },
+ mem_reservation: { type: ['string', 'integer'] },
+ mem_swappiness: { type: ['integer', 'string'] },
+ memswap_limit: { type: ['number', 'string'] },
+ network_mode: { type: 'string' },
+ networks: {
+ oneOf: [
+ { $ref: '#/definitions/list_of_strings' },
+ {
+ type: 'object',
+ properties: {},
+ patternProperties: {
+ '^[a-zA-Z0-9._-]+$': {
+ oneOf: [
+ { type: 'null' },
+ {
+ type: 'object',
+ properties: {
+ aliases: { $ref: '#/definitions/list_of_strings' },
+ ipv4_address: { type: 'string' },
+ ipv6_address: { type: 'string' },
+ link_local_ips: {
+ $ref: '#/definitions/list_of_strings',
+ },
+ priority: { type: ['number', 'string'] },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ ],
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ description:
+ 'The service will be included in these networks, allowing it to reach other containers on the same network.',
+ },
+ oom_kill_disable: { type: ['boolean', 'string'] },
+ oom_score_adj: {
+ oneOf: [
+ { type: 'string' },
+ { type: 'integer', minimum: -1000, maximum: 1000 },
+ ],
+ },
+ pid: { type: ['string', 'null'] },
+ pids_limit: { type: ['number', 'string'] },
+ platform: { type: 'string' },
+ ports: {
+ type: 'array',
+ items: {
+ oneOf: [
+ { type: ['number', 'string'] },
+ {
+ type: 'object',
+ properties: {
+ mode: { type: 'string' },
+ host_ip: { type: 'string' },
+ target: { type: ['number', 'string'] },
+ published: { type: ['number', 'string'] },
+ protocol: { type: 'string' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ ],
+ },
+ uniqueItems: true,
+ description: 'Ports that will be exposed to the host.',
+ },
+ post_start: {
+ type: 'array',
+ items: { $ref: '#/definitions/service_hook' },
+ },
+ pre_stop: {
+ type: 'array',
+ items: { $ref: '#/definitions/service_hook' },
+ },
+ privileged: { type: ['boolean', 'string'] },
+ profiles: {
+ type: 'array',
+ items: { type: 'string' },
+ uniqueItems: true,
+ description:
+ 'Profiles that this service is a part of. When the profile is started, this service will be started.',
+ },
+ pull_policy: {
+ type: 'string',
+ pattern:
+ 'always|never|build|if_not_present|missing|refresh|daily|weekly|every_([0-9]+[wdhms])+',
+ },
+ pull_refresh_after: { type: 'string' },
+ read_only: { type: ['boolean', 'string'] },
+ restart: { type: 'string' },
+ runtime: {
+ type: 'string',
+ },
+ scale: {
+ type: ['integer', 'string'],
+ },
+ security_opt: {
+ type: 'array',
+ items: { type: 'string' },
+ uniqueItems: true,
+ },
+ shm_size: { type: ['number', 'string'] },
+ secrets: {
+ $ref: '#/definitions/service_config_or_secret',
+ description: 'Secrets the service will have access to.',
+ },
+ sysctls: { $ref: '#/definitions/list_or_dict' },
+ stdin_open: { type: ['boolean', 'string'] },
+ stop_grace_period: { type: 'string' },
+ stop_signal: { type: 'string' },
+ storage_opt: { type: 'object' },
+ tmpfs: { $ref: '#/definitions/string_or_list' },
+ tty: { type: ['boolean', 'string'] },
+ ulimits: { $ref: '#/definitions/ulimits' },
+ user: {
+ type: 'string',
+ description:
+ 'The username under which the app in the container will be started.',
+ },
+ uts: { type: 'string' },
+ userns_mode: { type: 'string' },
+ volumes: {
+ type: 'array',
+ items: {
+ oneOf: [
+ { type: 'string' },
+ {
+ type: 'object',
+ required: ['type'],
+ properties: {
+ type: { type: 'string' },
+ source: { type: 'string' },
+ target: { type: 'string' },
+ read_only: { type: ['boolean', 'string'] },
+ consistency: { type: 'string' },
+ bind: {
+ type: 'object',
+ properties: {
+ propagation: { type: 'string' },
+ create_host_path: { type: ['boolean', 'string'] },
+ selinux: { type: 'string', enum: ['z', 'Z'] },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ volume: {
+ type: 'object',
+ properties: {
+ nocopy: { type: ['boolean', 'string'] },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ tmpfs: {
+ type: 'object',
+ properties: {
+ size: { type: ['number', 'string'] },
+ mode: { type: ['number', 'string'] },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ ],
+ },
+ uniqueItems: true,
+ description:
+ 'Named volumes and paths on the host mapped to paths in the container.',
+ },
+ volumes_from: {
+ type: 'array',
+ items: { type: 'string' },
+ uniqueItems: true,
+ },
+ working_dir: {
+ type: 'string',
+ description:
+ 'The working directory in which the entrypoint or command will be run.',
+ },
+ },
+ patternProperties: { '^x-': {} },
+ additionalProperties: false,
+ },
+
+ healthcheck: {
+ type: 'object',
+ properties: {
+ disable: { type: ['boolean', 'string'] },
+ interval: { type: 'string' },
+ retries: { type: ['number', 'string'] },
+ test: {
+ oneOf: [
+ { type: 'string' },
+ { type: 'array', items: { type: 'string' } },
+ ],
+ },
+ timeout: { type: 'string' },
+ start_period: { type: 'string' },
+ start_interval: { type: 'string' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ development: {
+ type: ['object', 'null'],
+ properties: {
+ watch: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: ['path', 'action'],
+ properties: {
+ ignore: { $ref: '#/definitions/string_or_list' },
+ include: { $ref: '#/definitions/string_or_list' },
+ path: { type: 'string' },
+ action: {
+ type: 'string',
+ enum: [
+ 'rebuild',
+ 'sync',
+ 'restart',
+ 'sync+restart',
+ 'sync+exec',
+ ],
+ },
+ target: { type: 'string' },
+ exec: { $ref: '#/definitions/service_hook' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ deployment: {
+ type: ['object', 'null'],
+ properties: {
+ mode: { type: 'string' },
+ endpoint_mode: { type: 'string' },
+ replicas: { type: ['integer', 'string'] },
+ labels: { $ref: '#/definitions/list_or_dict' },
+ rollback_config: {
+ type: 'object',
+ properties: {
+ parallelism: { type: ['integer', 'string'] },
+ delay: { type: 'string' },
+ failure_action: { type: 'string' },
+ monitor: { type: 'string' },
+ max_failure_ratio: { type: ['number', 'string'] },
+ order: { type: 'string', enum: ['start-first', 'stop-first'] },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ update_config: {
+ type: 'object',
+ properties: {
+ parallelism: { type: ['integer', 'string'] },
+ delay: { type: 'string' },
+ failure_action: { type: 'string' },
+ monitor: { type: 'string' },
+ max_failure_ratio: { type: ['number', 'string'] },
+ order: { type: 'string', enum: ['start-first', 'stop-first'] },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ resources: {
+ type: 'object',
+ properties: {
+ limits: {
+ type: 'object',
+ properties: {
+ cpus: { type: ['number', 'string'] },
+ memory: { type: 'string' },
+ pids: { type: ['integer', 'string'] },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ reservations: {
+ type: 'object',
+ properties: {
+ cpus: { type: ['number', 'string'] },
+ memory: { type: 'string' },
+ generic_resources: { $ref: '#/definitions/generic_resources' },
+ devices: { $ref: '#/definitions/devices' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ restart_policy: {
+ type: 'object',
+ properties: {
+ condition: { type: 'string' },
+ delay: { type: 'string' },
+ max_attempts: { type: ['integer', 'string'] },
+ window: { type: 'string' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ placement: {
+ type: 'object',
+ properties: {
+ constraints: { type: 'array', items: { type: 'string' } },
+ preferences: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ spread: { type: 'string' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ },
+ max_replicas_per_node: { type: ['integer', 'string'] },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+
+ generic_resources: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ discrete_resource_spec: {
+ type: 'object',
+ properties: {
+ kind: { type: 'string' },
+ value: { type: ['number', 'string'] },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ },
+
+ devices: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ capabilities: { $ref: '#/definitions/list_of_strings' },
+ count: { type: ['string', 'integer'] },
+ device_ids: { $ref: '#/definitions/list_of_strings' },
+ driver: { type: 'string' },
+ options: { $ref: '#/definitions/list_or_dict' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ required: ['capabilities'],
+ },
+ },
+
+ gpus: {
+ oneOf: [
+ { type: 'string', enum: ['all'] },
+ {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ capabilities: { $ref: '#/definitions/list_of_strings' },
+ count: { type: ['string', 'integer'] },
+ device_ids: { $ref: '#/definitions/list_of_strings' },
+ driver: { type: 'string' },
+ options: { $ref: '#/definitions/list_or_dict' },
+ },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ ],
+ },
+
+ include: {
+ oneOf: [
+ { type: 'string' },
+ {
+ type: 'object',
+ properties: {
+ path: { $ref: '#/definitions/string_or_list' },
+ env_file: { $ref: '#/definitions/string_or_list' },
+ project_directory: { type: 'string' },
+ },
+ additionalProperties: false,
+ },
+ ],
+ },
+
+ network: {
+ type: ['object', 'null'],
+ properties: {
+ name: { type: 'string' },
+ driver: {
+ type: 'string',
+ description: 'The driver used for this network.',
+ },
+ driver_opts: {
+ type: 'object',
+ patternProperties: {
+ '^.+$': { type: ['string', 'number'] },
+ },
+ },
+ ipam: {
+ type: 'object',
+ properties: {
+ driver: { type: 'string' },
+ config: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ subnet: { type: 'string' },
+ ip_range: { type: 'string' },
+ gateway: { type: 'string' },
+ aux_addresses: {
+ type: 'object',
+ additionalProperties: false,
+ patternProperties: { '^.+$': { type: 'string' } },
+ },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ },
+ options: {
+ type: 'object',
+ additionalProperties: false,
+ patternProperties: { '^.+$': { type: 'string' } },
+ },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ external: {
+ type: ['boolean', 'string', 'object'],
+ properties: {
+ name: {
+ deprecated: true,
+ type: 'string',
+ },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ internal: { type: ['boolean', 'string'] },
+ enable_ipv4: { type: ['boolean', 'string'] },
+ enable_ipv6: { type: ['boolean', 'string'] },
+ attachable: { type: ['boolean', 'string'] },
+ labels: { $ref: '#/definitions/list_or_dict' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+
+ volume: {
+ type: ['object', 'null'],
+ properties: {
+ name: { type: 'string' },
+ driver: {
+ type: 'string',
+ description: 'The driver used for this volume.',
+ },
+ driver_opts: {
+ type: 'object',
+ patternProperties: {
+ '^.+$': { type: ['string', 'number'] },
+ },
+ },
+ external: {
+ type: ['boolean', 'string', 'object'],
+ properties: {
+ name: {
+ deprecated: true,
+ type: 'string',
+ },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ labels: { $ref: '#/definitions/list_or_dict' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+
+ secret: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ environment: { type: 'string' },
+ file: { type: 'string' },
+ external: {
+ type: ['boolean', 'string', 'object'],
+ properties: {
+ name: { type: 'string' },
+ },
+ },
+ labels: { $ref: '#/definitions/list_or_dict' },
+ driver: { type: 'string' },
+ driver_opts: {
+ type: 'object',
+ patternProperties: {
+ '^.+$': { type: ['string', 'number'] },
+ },
+ },
+ template_driver: { type: 'string' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+
+ config: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ content: { type: 'string' },
+ environment: { type: 'string' },
+ file: { type: 'string' },
+ external: {
+ type: ['boolean', 'string', 'object'],
+ properties: {
+ name: {
+ deprecated: true,
+ type: 'string',
+ },
+ },
+ },
+ labels: { $ref: '#/definitions/list_or_dict' },
+ template_driver: { type: 'string' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+
+ command: {
+ oneOf: [
+ { type: 'null' },
+ { type: 'string' },
+ { type: 'array', items: { type: 'string' } },
+ ],
+ },
+
+ service_hook: {
+ type: 'object',
+ properties: {
+ command: { $ref: '#/definitions/command' },
+ user: { type: 'string' },
+ privileged: { type: ['boolean', 'string'] },
+ working_dir: { type: 'string' },
+ environment: { $ref: '#/definitions/list_or_dict' },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ required: ['command'],
+ },
+
+ env_file: {
+ oneOf: [
+ { type: 'string' },
+ {
+ type: 'array',
+ items: {
+ oneOf: [
+ { type: 'string' },
+ {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ path: {
+ type: 'string',
+ },
+ format: {
+ type: 'string',
+ },
+ required: {
+ type: ['boolean', 'string'],
+ default: true,
+ },
+ },
+ required: ['path'],
+ },
+ ],
+ },
+ },
+ ],
+ },
+
+ label_file: {
+ oneOf: [
+ { type: 'string' },
+ {
+ type: 'array',
+ items: { type: 'string' },
+ },
+ ],
+ },
+
+ string_or_list: {
+ oneOf: [{ type: 'string' }, { $ref: '#/definitions/list_of_strings' }],
+ },
+
+ list_of_strings: {
+ type: 'array',
+ items: { type: 'string' },
+ uniqueItems: true,
+ },
+
+ list_or_dict: {
+ oneOf: [
+ {
+ type: 'object',
+ patternProperties: {
+ '.+': {
+ type: ['string', 'number', 'boolean', 'null'],
+ },
+ },
+ additionalProperties: false,
+ },
+ { type: 'array', items: { type: 'string' }, uniqueItems: true },
+ ],
+ },
+
+ extra_hosts: {
+ oneOf: [
+ {
+ type: 'object',
+ patternProperties: {
+ '.+': {
+ oneOf: [
+ {
+ type: 'string',
+ },
+ {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ uniqueItems: false,
+ },
+ ],
+ },
+ },
+ additionalProperties: false,
+ },
+ { type: 'array', items: { type: 'string' }, uniqueItems: true },
+ ],
+ },
+
+ blkio_limit: {
+ type: 'object',
+ properties: {
+ path: { type: 'string' },
+ rate: { type: ['integer', 'string'] },
+ },
+ additionalProperties: false,
+ },
+ blkio_weight: {
+ type: 'object',
+ properties: {
+ path: { type: 'string' },
+ weight: { type: ['integer', 'string'] },
+ },
+ additionalProperties: false,
+ },
+ service_config_or_secret: {
+ type: 'array',
+ items: {
+ oneOf: [
+ { type: 'string' },
+ {
+ type: 'object',
+ properties: {
+ source: { type: 'string' },
+ target: { type: 'string' },
+ uid: { type: 'string' },
+ gid: { type: 'string' },
+ mode: { type: ['number', 'string'] },
+ },
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ ],
+ },
+ },
+ ulimits: {
+ type: 'object',
+ patternProperties: {
+ '^[a-z]+$': {
+ oneOf: [
+ { type: ['integer', 'string'] },
+ {
+ type: 'object',
+ properties: {
+ hard: { type: ['integer', 'string'] },
+ soft: { type: ['integer', 'string'] },
+ },
+ required: ['soft', 'hard'],
+ additionalProperties: false,
+ patternProperties: { '^x-': {} },
+ },
+ ],
+ },
+ },
+ },
+ },
+};
diff --git a/app/react/hooks/useDockerComposeSchema/useDockerComposeSchema.ts b/app/react/hooks/useDockerComposeSchema/useDockerComposeSchema.ts
new file mode 100644
index 000000000..8c9578d8a
--- /dev/null
+++ b/app/react/hooks/useDockerComposeSchema/useDockerComposeSchema.ts
@@ -0,0 +1,37 @@
+import { JSONSchema7 } from 'json-schema';
+import { useQuery } from '@tanstack/react-query';
+import axios from 'axios';
+
+import { dockerComposeSchema } from './docker-compose-schema';
+
+const COMPOSE_SCHEMA_URL =
+ 'https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json';
+
+export function useDockerComposeSchema() {
+ return useQuery(
+ ['docker-compose-schema'],
+ getDockerComposeSchema,
+ {
+ staleTime: 24 * 60 * 60 * 1000, // 24 hours
+ cacheTime: 30 * 24 * 60 * 60 * 1000, // 30 days
+ retry: 1,
+ refetchOnWindowFocus: false,
+ // Start with local schema while fetching
+ initialData: dockerComposeSchema as JSONSchema7,
+ }
+ );
+}
+
+export async function getDockerComposeSchema() {
+ try {
+ const response = await axios.get(COMPOSE_SCHEMA_URL);
+ // just in case a non-object is returned from a proxy
+ if (typeof response.data !== 'object') {
+ return dockerComposeSchema as JSONSchema7;
+ }
+ return response.data;
+ } catch (error) {
+ // Return the local schema as fallback for airgapped environments
+ return dockerComposeSchema as JSONSchema7;
+ }
+}
diff --git a/app/setup-tests/mock-codemirror.tsx b/app/setup-tests/mock-codemirror.tsx
new file mode 100644
index 000000000..a6ffc8eda
--- /dev/null
+++ b/app/setup-tests/mock-codemirror.tsx
@@ -0,0 +1,16 @@
+export function mockCodeMirror() {
+ vi.mock('@uiw/react-codemirror', () => ({
+ __esModule: true,
+ default: () =>
,
+ oneDarkHighlightStyle: {},
+ keymap: {
+ of: () => ({}),
+ },
+ }));
+ vi.mock('yaml-schema', () => ({
+ yamlSchema: () => [],
+ validation: () => ({
+ of: () => ({}),
+ }),
+ }));
+}
diff --git a/app/setup-tests/setup-codemirror.ts b/app/setup-tests/setup-codemirror.ts
new file mode 100644
index 000000000..e5f83fffc
--- /dev/null
+++ b/app/setup-tests/setup-codemirror.ts
@@ -0,0 +1,42 @@
+import 'vitest-dom/extend-expect';
+
+// Mock Range APIs that CodeMirror needs but JSDOM doesn't provide
+Range.prototype.getBoundingClientRect = () => ({
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ x: 0,
+ y: 0,
+ toJSON: vi.fn(),
+});
+
+Range.prototype.getClientRects = () => ({
+ item: () => null,
+ length: 0,
+ [Symbol.iterator]: vi.fn(),
+});
+
+// Mock createRange
+document.createRange = () => {
+ const range = new Range();
+ range.getBoundingClientRect = vi.fn();
+ range.getClientRects = () => ({
+ item: () => null,
+ length: 0,
+ [Symbol.iterator]: vi.fn(),
+ });
+ return range;
+};
+
+// Mock selection APIs
+const mockSelection = {
+ rangeCount: 0,
+ addRange: vi.fn(),
+ getRangeAt: vi.fn(),
+ removeAllRanges: vi.fn(),
+};
+
+window.getSelection = () => mockSelection as unknown as Selection;
diff --git a/package.json b/package.json
index 9e28a2441..889d9e8da 100644
--- a/package.json
+++ b/package.json
@@ -35,9 +35,10 @@
"dependencies": {
"@aws-crypto/sha256-js": "^2.0.0",
"@codemirror/autocomplete": "^6.4.0",
+ "@codemirror/commands": "^6.8.0",
"@codemirror/language": "^6.3.2",
"@codemirror/legacy-modes": "^6.3.1",
- "@codemirror/lint": "^6.1.0",
+ "@codemirror/lint": "^6.8.4",
"@codemirror/search": "^6.2.3",
"@codemirror/state": "^6.2.0",
"@codemirror/theme-one-dark": "^6.1.0",
@@ -86,6 +87,7 @@
"chart.js": "^2.7.0",
"clsx": "^1.1.1",
"codemirror": "^6.0.1",
+ "codemirror-json-schema": "^0.8.0",
"core-js": "^3.19.3",
"date-fns": "^2.29.3",
"docker-types": "^1.43.1",
@@ -101,6 +103,7 @@
"js-base64": "^3.7.2",
"js-yaml": "^3.14.0",
"jsdom": "^24.0.0",
+ "json-schema": "^0.4.0",
"lodash": "^4.17.21",
"lucide-react": "^0.468.0",
"moment": "^2.29.1",
diff --git a/tsconfig.json b/tsconfig.json
index ab2985ba4..20380af1c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -36,7 +36,9 @@
"Azure/*": ["azure/*"],
"Docker/*": ["docker/*"],
"Kubernetes/*": ["kubernetes/*"],
- "Portainer/*": ["portainer/*"]
+ "Portainer/*": ["portainer/*"],
+ // https://github.com/jsonnext/codemirror-json-schema/issues/107#issuecomment-2144584296
+ "yaml-schema": ["../node_modules/codemirror-json-schema/dist/yaml"]
},
"types": ["vitest/globals"]
},
diff --git a/vitest.config.mts b/vitest.config.mts
index fed00b8c4..eca3b62ed 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -7,10 +7,16 @@ export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
- setupFiles: ['./app/setup-tests/setup-msw.ts', './app/setup-tests/stub-modules.ts', './app/setup-tests/setup.ts', './app/setup-tests/setup-rtl.ts'],
+ setupFiles: [
+ './app/setup-tests/setup-msw.ts',
+ './app/setup-tests/stub-modules.ts',
+ './app/setup-tests/setup.ts',
+ './app/setup-tests/setup-codemirror.ts',
+ './app/setup-tests/setup-rtl.ts',
+ ],
coverage: {
provider: 'v8',
- reporter: ['text', 'json', 'html'],
+ reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'app/setup-tests/global-setup.js'],
},
bail: 2,
diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js
index 856ca2726..5d2879ce8 100644
--- a/webpack/webpack.common.js
+++ b/webpack/webpack.common.js
@@ -89,6 +89,12 @@ module.exports = {
},
],
},
+ {
+ test: /\.m?js/,
+ resolve: {
+ fullySpecified: false,
+ },
+ },
],
},
devServer: {
@@ -188,6 +194,7 @@ module.exports = {
Kubernetes: path.resolve(projectRoot, 'app/kubernetes'),
Portainer: path.resolve(projectRoot, 'app/portainer'),
'lodash-es': 'lodash',
+ 'yaml-schema': path.resolve(projectRoot, 'node_modules/codemirror-json-schema/dist/yaml'),
},
extensions: ['.js', '.ts', '.tsx'],
plugins: [
diff --git a/yarn.lock b/yarn.lock
index f2975f9c7..638c32535 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3166,7 +3166,17 @@
"@codemirror/view" "^6.17.0"
"@lezer/common" "^1.0.0"
-"@codemirror/commands@^6.0.0", "@codemirror/commands@^6.1.0":
+"@codemirror/autocomplete@^6.16.2":
+ version "6.18.6"
+ resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz#de26e864a1ec8192a1b241eb86addbb612964ddb"
+ integrity sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+ "@codemirror/state" "^6.0.0"
+ "@codemirror/view" "^6.17.0"
+ "@lezer/common" "^1.0.0"
+
+"@codemirror/commands@^6.0.0", "@codemirror/commands@^6.1.0", "@codemirror/commands@^6.8.0":
version "6.8.0"
resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.8.0.tgz#92f200b66f852939bd6ebb90d48c2d9e9c813d64"
integrity sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==
@@ -3176,6 +3186,27 @@
"@codemirror/view" "^6.27.0"
"@lezer/common" "^1.1.0"
+"@codemirror/lang-json@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-json/-/lang-json-6.0.1.tgz#0a0be701a5619c4b0f8991f9b5e95fe33f462330"
+ integrity sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+ "@lezer/json" "^1.0.0"
+
+"@codemirror/lang-yaml@^6.1.1":
+ version "6.1.2"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz#c84280c68fa7af456a355d91183b5e537e9b7038"
+ integrity sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==
+ dependencies:
+ "@codemirror/autocomplete" "^6.0.0"
+ "@codemirror/language" "^6.0.0"
+ "@codemirror/state" "^6.0.0"
+ "@lezer/common" "^1.2.0"
+ "@lezer/highlight" "^1.2.0"
+ "@lezer/lr" "^1.0.0"
+ "@lezer/yaml" "^1.0.0"
+
"@codemirror/language@^6.0.0", "@codemirror/language@^6.3.2":
version "6.10.8"
resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.10.8.tgz#3e3a346a2b0a8cf63ee1cfe03349eb1965dce5f9"
@@ -3195,7 +3226,7 @@
dependencies:
"@codemirror/language" "^6.0.0"
-"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.1.0":
+"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.8.4":
version "6.8.4"
resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.8.4.tgz#7d8aa5d1a6dec89ffcc23ad45ddca2e12e90982d"
integrity sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==
@@ -3892,7 +3923,7 @@
resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b"
integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==
-"@lezer/common@^1.0.0", "@lezer/common@^1.1.0":
+"@lezer/common@^1.0.0", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.3.tgz#138fcddab157d83da557554851017c6c1e5667fd"
integrity sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==
@@ -3902,7 +3933,7 @@
resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.0.2.tgz#8fb9b86bdaa2ece57e7d59e5ffbcb37d71815087"
integrity sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng==
-"@lezer/highlight@^1.0.0":
+"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.2.0":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.1.tgz#596fa8f9aeb58a608be0a563e960c373cbf23f8b"
integrity sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==
@@ -3916,13 +3947,31 @@
dependencies:
"@lezer/common" "^1.0.0"
-"@lezer/lr@^1.0.0":
+"@lezer/json@^1.0.0":
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/@lezer/json/-/json-1.0.3.tgz#e773a012ad0088fbf07ce49cfba875cc9e5bc05f"
+ integrity sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==
+ dependencies:
+ "@lezer/common" "^1.2.0"
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@lezer/lr@^1.0.0", "@lezer/lr@^1.4.0":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.2.tgz#931ea3dea8e9de84e90781001dae30dea9ff1727"
integrity sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==
dependencies:
"@lezer/common" "^1.0.0"
+"@lezer/yaml@^1.0.0":
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/@lezer/yaml/-/yaml-1.0.3.tgz#b23770ab42b390056da6b187d861b998fd60b1ff"
+ integrity sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==
+ dependencies:
+ "@lezer/common" "^1.2.0"
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.4.0"
+
"@ljharb/through@^2.3.9":
version "2.3.9"
resolved "https://registry.yarnpkg.com/@ljharb/through/-/through-2.3.9.tgz#85f221eb82f9d555e180e87d6e50fb154af85408"
@@ -4606,6 +4655,83 @@
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz#2c1fb69e02a3f1506f52698cfdc3a8b6386df9a6"
integrity sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==
+"@sagold/json-pointer@^5.1.1", "@sagold/json-pointer@^5.1.2":
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/@sagold/json-pointer/-/json-pointer-5.1.2.tgz#7f07884050fd2139eeb5d7423e917160ee7e0b8d"
+ integrity sha512-+wAhJZBXa6MNxRScg6tkqEbChEHMgVZAhTHVJ60Y7sbtXtu9XA49KfUkdWlS2x78D6H9nryiKePiYozumauPfA==
+
+"@sagold/json-query@^6.1.3":
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/@sagold/json-query/-/json-query-6.2.0.tgz#2204a0259ea10f36cd5cb0a1505078e6d751eecd"
+ integrity sha512-7bOIdUE6eHeoWtFm8TvHQHfTVSZuCs+3RpOKmZCDBIOrxpvF/rNFTeuvIyjHva/RR0yVS3kQtr+9TW72LQEZjA==
+ dependencies:
+ "@sagold/json-pointer" "^5.1.2"
+ ebnf "^1.9.1"
+
+"@shikijs/core@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-1.29.2.tgz#9c051d3ac99dd06ae46bd96536380c916e552bf3"
+ integrity sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==
+ dependencies:
+ "@shikijs/engine-javascript" "1.29.2"
+ "@shikijs/engine-oniguruma" "1.29.2"
+ "@shikijs/types" "1.29.2"
+ "@shikijs/vscode-textmate" "^10.0.1"
+ "@types/hast" "^3.0.4"
+ hast-util-to-html "^9.0.4"
+
+"@shikijs/engine-javascript@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/engine-javascript/-/engine-javascript-1.29.2.tgz#a821ad713a3e0b7798a1926fd9e80116e38a1d64"
+ integrity sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A==
+ dependencies:
+ "@shikijs/types" "1.29.2"
+ "@shikijs/vscode-textmate" "^10.0.1"
+ oniguruma-to-es "^2.2.0"
+
+"@shikijs/engine-oniguruma@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/engine-oniguruma/-/engine-oniguruma-1.29.2.tgz#d879717ced61d44e78feab16f701f6edd75434f1"
+ integrity sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA==
+ dependencies:
+ "@shikijs/types" "1.29.2"
+ "@shikijs/vscode-textmate" "^10.0.1"
+
+"@shikijs/langs@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/langs/-/langs-1.29.2.tgz#4f1de46fde8991468c5a68fa4a67dd2875d643cd"
+ integrity sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ==
+ dependencies:
+ "@shikijs/types" "1.29.2"
+
+"@shikijs/markdown-it@^1.22.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/markdown-it/-/markdown-it-1.29.2.tgz#d46bd8c4c61ff054c69b2cba89feb2b951a2f65c"
+ integrity sha512-RPHqGU8RGQZ2TGMnEqLnSyM9CjPSjb0f8bwSLnJgBmWPWguoygoaFyYkXG0kwMtBtChNYsqQz1C0fLcbo6dY8g==
+ dependencies:
+ markdown-it "^14.1.0"
+ shiki "1.29.2"
+
+"@shikijs/themes@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/themes/-/themes-1.29.2.tgz#293cc5c83dd7df3fdc8efa25cec8223f3a6acb0d"
+ integrity sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g==
+ dependencies:
+ "@shikijs/types" "1.29.2"
+
+"@shikijs/types@1.29.2":
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-1.29.2.tgz#a93fdb410d1af8360c67bf5fc1d1a68d58e21c4f"
+ integrity sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw==
+ dependencies:
+ "@shikijs/vscode-textmate" "^10.0.1"
+ "@types/hast" "^3.0.4"
+
+"@shikijs/vscode-textmate@^10.0.1":
+ version "10.0.2"
+ resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz#a90ab31d0cc1dfb54c66a69e515bf624fa7b2224"
+ integrity sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==
+
"@simbathesailor/use-what-changed@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz#7f82d78f92c8588b5fadd702065dde93bd781403"
@@ -5961,6 +6087,13 @@
dependencies:
"@types/node" "*"
+"@types/hast@^3.0.0", "@types/hast@^3.0.4":
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa"
+ integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==
+ dependencies:
+ "@types/unist" "*"
+
"@types/html-minifier-terser@^6.0.0":
version "6.1.0"
resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"
@@ -6062,6 +6195,13 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.177.tgz#f70c0d19c30fab101cad46b52be60363c43c4578"
integrity sha512-0fDwydE2clKe9MNfvXHBHF9WEahRuj+msTuQqOmAApNORFvhMYZKNGGJdCzuhheVjMps/ti0Ak/iJPACMaevvw==
+"@types/mdast@^4.0.0":
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6"
+ integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==
+ dependencies:
+ "@types/unist" "*"
+
"@types/mdx@^2.0.0":
version "2.0.5"
resolved "https://registry.yarnpkg.com/@types/mdx/-/mdx-2.0.5.tgz#9a85a8f70c7c4d9e695a21d5ae5c93645eda64b1"
@@ -6309,6 +6449,11 @@
dependencies:
"@types/jquery" "*"
+"@types/unist@*", "@types/unist@^3.0.0":
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c"
+ integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==
+
"@types/unist@^2.0.0":
version "2.0.6"
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
@@ -6600,6 +6745,11 @@
"@uiw/codemirror-extensions-basic-setup" "4.23.7"
codemirror "^6.0.0"
+"@ungap/structured-clone@^1.0.0":
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
+ integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==
+
"@vitest/coverage-v8@^2.0.4":
version "2.1.8"
resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-2.1.8.tgz#738527e6e79cef5004248452527e272e0df12284"
@@ -7190,6 +7340,11 @@ argparse@^1.0.7:
dependencies:
sprintf-js "~1.0.2"
+argparse@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
+ integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
+
aria-hidden@^1.1.1:
version "1.2.3"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.3.tgz#14aeb7fb692bbb72d69bebfa47279c1fd725e954"
@@ -7635,6 +7790,11 @@ batch@0.6.1:
resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
integrity sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=
+best-effort-json-parser@^1.1.2:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/best-effort-json-parser/-/best-effort-json-parser-1.1.3.tgz#aff97716cbe649e5aa31ffa614a19763f43cde3a"
+ integrity sha512-O3LfmiLJ5UQOGqrrl6ynCdfDgK50cd0nxy0JacFZ7ARhfhjdksTfScHAJ0580RNgNejLjRvu/7Yj9znY0sqeFA==
+
better-opn@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.2.tgz#f96f35deaaf8f34144a4102651babcf00d1d8817"
@@ -7996,6 +8156,11 @@ case-sensitive-paths-webpack-plugin@^2.4.0:
resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4"
integrity sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==
+ccount@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
+ integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
+
chai@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/chai/-/chai-5.1.2.tgz#3afbc340b994ae3610ca519a6c70ace77ad4378d"
@@ -8052,6 +8217,16 @@ change-case@^4.1.2:
snake-case "^3.0.4"
tslib "^2.0.3"
+character-entities-html4@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b"
+ integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==
+
+character-entities-legacy@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b"
+ integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==
+
chardet@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
@@ -8270,6 +8445,40 @@ clsx@^2.0.0:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.0.tgz#e851283bcb5c80ee7608db18487433f7b23f77cb"
integrity sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==
+codemirror-json-schema@^0.8.0:
+ version "0.8.0"
+ resolved "https://registry.yarnpkg.com/codemirror-json-schema/-/codemirror-json-schema-0.8.0.tgz#3e8b39a038148645c23a006e505becce68397602"
+ integrity sha512-Hiww3eU/8zwPw6oWT2VNTT7w7vwWzFiE7+syleD8rYg5pCfKuGa2oiAAgZCXpx6EUJtIcq3LKP9gkEbNUD5jcA==
+ dependencies:
+ "@sagold/json-pointer" "^5.1.1"
+ "@shikijs/markdown-it" "^1.22.2"
+ best-effort-json-parser "^1.1.2"
+ json-schema "^0.4.0"
+ json-schema-library "^9.3.5"
+ loglevel "^1.9.1"
+ markdown-it "^14.1.0"
+ shiki "^1.22.2"
+ yaml "^2.3.4"
+ optionalDependencies:
+ "@codemirror/autocomplete" "^6.16.2"
+ "@codemirror/lang-json" "^6.0.1"
+ "@codemirror/lang-yaml" "^6.1.1"
+ codemirror-json5 "^1.0.3"
+ json5 "^2.2.3"
+
+codemirror-json5@^1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/codemirror-json5/-/codemirror-json5-1.0.3.tgz#046101609776a97ae3bd0bfc4b9f15638e317793"
+ integrity sha512-HmmoYO2huQxoaoG5ARKjqQc9mz7/qmNPvMbISVfIE2Gk1+4vZQg9X3G6g49MYM5IK00Ol3aijd7OKrySuOkA7Q==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+ "@codemirror/state" "^6.0.0"
+ "@codemirror/view" "^6.0.0"
+ "@lezer/common" "^1.0.0"
+ "@lezer/highlight" "^1.0.0"
+ json5 "^2.2.1"
+ lezer-json5 "^2.0.2"
+
codemirror@^6.0.0, codemirror@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-6.0.1.tgz#62b91142d45904547ee3e0e0e4c1a79158035a29"
@@ -8363,6 +8572,11 @@ combined-stream@^1.0.8:
dependencies:
delayed-stream "~1.0.0"
+comma-separated-tokens@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee"
+ integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==
+
commander@11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67"
@@ -8378,7 +8592,7 @@ commander@^10.0.1:
resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==
-commander@^2.20.0, commander@^2.7.1, commander@^2.8.1:
+commander@^2.19.0, commander@^2.20.0, commander@^2.7.1, commander@^2.8.1:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
@@ -9073,7 +9287,7 @@ depd@~1.1.2:
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==
-dequal@^2.0.2, dequal@^2.0.3:
+dequal@^2.0.0, dequal@^2.0.2, dequal@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
@@ -9123,6 +9337,13 @@ detect-port@^1.3.0:
address "^1.0.1"
debug "4"
+devlop@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018"
+ integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==
+ dependencies:
+ dequal "^2.0.0"
+
didyoumean@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
@@ -9135,6 +9356,11 @@ dir-glob@^3.0.1:
dependencies:
path-type "^4.0.0"
+discontinuous-range@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a"
+ integrity sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==
+
dlv@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
@@ -9308,6 +9534,11 @@ eastasianwidth@^0.2.0:
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
+ebnf@^1.9.1:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/ebnf/-/ebnf-1.9.1.tgz#64c25d8208ec0d221ec11c3c5e8094015131a9d3"
+ integrity sha512-uW2UKSsuty9ANJ3YByIQE4ANkD8nqUPO7r6Fwcc1ADKPe9FRdcPpMl3VEput4JSvKBJ4J86npIC2MLP0pYkCuw==
+
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@@ -9345,6 +9576,11 @@ electron-to-chromium@^1.4.601:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.616.tgz#4bddbc2c76e1e9dbf449ecd5da3d8119826ea4fb"
integrity sha512-1n7zWYh8eS0L9Uy+GskE0lkBUNK83cXTVJI0pU3mGprFsbfSdAc15VTFbo+A+Bq4pwstmL30AVcEU3Fo463lNg==
+emoji-regex-xs@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz#e8af22e5d9dbd7f7f22d280af3d19d2aab5b0724"
+ integrity sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==
+
emoji-regex@^10.2.1:
version "10.2.1"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.2.1.tgz#a41c330d957191efd3d9dfe6e1e8e1e9ab048b3f"
@@ -10157,6 +10393,11 @@ extract-zip@^1.6.6:
mkdirp "^0.5.4"
yauzl "^2.10.0"
+fast-copy@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-3.0.2.tgz#59c68f59ccbcac82050ba992e0d5c389097c9d35"
+ integrity sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==
+
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
@@ -11078,6 +11319,30 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
+hast-util-to-html@^9.0.4:
+ version "9.0.5"
+ resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005"
+ integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ "@types/unist" "^3.0.0"
+ ccount "^2.0.0"
+ comma-separated-tokens "^2.0.0"
+ hast-util-whitespace "^3.0.0"
+ html-void-elements "^3.0.0"
+ mdast-util-to-hast "^13.0.0"
+ property-information "^7.0.0"
+ space-separated-tokens "^2.0.0"
+ stringify-entities "^4.0.0"
+ zwitch "^2.0.4"
+
+hast-util-whitespace@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621"
+ integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==
+ dependencies:
+ "@types/hast" "^3.0.0"
+
he@1.2.x, he@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
@@ -11204,6 +11469,11 @@ html-tags@^3.1.0:
resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce"
integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==
+html-void-elements@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7"
+ integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==
+
html-webpack-plugin@^5.5.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz#c3911936f57681c1f9f4d8b68c158cd9dfe52f50"
@@ -12229,6 +12499,19 @@ json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1:
resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
+json-schema-library@^9.3.5:
+ version "9.3.5"
+ resolved "https://registry.yarnpkg.com/json-schema-library/-/json-schema-library-9.3.5.tgz#dd66002257a13572acd681854a023e1da0e86bfa"
+ integrity sha512-5eBDx7cbfs+RjylsVO+N36b0GOPtv78rfqgf2uON+uaHUIC62h63Y8pkV2ovKbaL4ZpQcHp21968x5nx/dFwqQ==
+ dependencies:
+ "@sagold/json-pointer" "^5.1.2"
+ "@sagold/json-query" "^6.1.3"
+ deepmerge "^4.3.1"
+ fast-copy "^3.0.2"
+ fast-deep-equal "^3.1.3"
+ smtp-address-parser "1.0.10"
+ valid-url "^1.0.9"
+
json-schema-traverse@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
@@ -12239,6 +12522,11 @@ json-schema-traverse@^1.0.0:
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
+json-schema@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
+ integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==
+
json-stable-stringify-without-jsonify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
@@ -12258,7 +12546,7 @@ json5@^1.0.1, json5@^1.0.2:
dependencies:
minimist "^1.2.0"
-json5@^2.1.2, json5@^2.2.2, json5@^2.2.3:
+json5@^2.1.2, json5@^2.2.1, json5@^2.2.2, json5@^2.2.3:
version "2.2.3"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
@@ -12368,6 +12656,13 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
+lezer-json5@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/lezer-json5/-/lezer-json5-2.0.2.tgz#ba3756e0d352d9529517dcf264d27db8579ae577"
+ integrity sha512-NRmtBlKW/f8mA7xatKq8IUOq045t8GVHI4kZXrUtYYUdiVeGiO6zKGAV7/nUAnf5q+rYTY+SWX/gvQdFXMjNxQ==
+ dependencies:
+ "@lezer/lr" "^1.0.0"
+
liftoff@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-4.0.0.tgz#1a463b9073335cd425cdaa3b468996f7d66d2d81"
@@ -12397,6 +12692,13 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
+linkify-it@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
+ integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
+ dependencies:
+ uc.micro "^2.0.0"
+
lint-staged@^14.0.1:
version "14.0.1"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-14.0.1.tgz#57dfa3013a3d60762d9af5d9c83bdb51291a6232"
@@ -12592,6 +12894,11 @@ logform@^2.3.2:
safe-stable-stringify "^1.1.0"
triple-beam "^1.3.0"
+loglevel@^1.9.1:
+ version "1.9.2"
+ resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.2.tgz#c2e028d6c757720107df4e64508530db6621ba08"
+ integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==
+
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
@@ -12736,6 +13043,18 @@ map-or-similar@^1.5.0:
resolved "https://registry.yarnpkg.com/map-or-similar/-/map-or-similar-1.5.0.tgz#6de2653174adfb5d9edc33c69d3e92a1b76faf08"
integrity sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==
+markdown-it@^14.1.0:
+ version "14.1.0"
+ resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
+ integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
+ dependencies:
+ argparse "^2.0.1"
+ entities "^4.4.0"
+ linkify-it "^5.0.0"
+ mdurl "^2.0.0"
+ punycode.js "^2.3.1"
+ uc.micro "^2.1.0"
+
markdown-to-jsx@^7.1.8:
version "7.2.0"
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.2.0.tgz#e7b46b65955f6a04d48a753acd55874a14bdda4b"
@@ -12757,6 +13076,21 @@ mdast-util-definitions@^4.0.0:
dependencies:
unist-util-visit "^2.0.0"
+mdast-util-to-hast@^13.0.0:
+ version "13.2.0"
+ resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz#5ca58e5b921cc0a3ded1bc02eed79a4fe4fe41f4"
+ integrity sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ "@types/mdast" "^4.0.0"
+ "@ungap/structured-clone" "^1.0.0"
+ devlop "^1.0.0"
+ micromark-util-sanitize-uri "^2.0.0"
+ trim-lines "^3.0.0"
+ unist-util-position "^5.0.0"
+ unist-util-visit "^5.0.0"
+ vfile "^6.0.0"
+
mdast-util-to-string@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz#27055500103f51637bd07d01da01eb1967a43527"
@@ -12772,6 +13106,11 @@ mdn-data@2.0.30:
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc"
integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==
+mdurl@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
+ integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
+
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -12823,6 +13162,38 @@ methods@~1.1.2:
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
+micromark-util-character@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6"
+ integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==
+ dependencies:
+ micromark-util-symbol "^2.0.0"
+ micromark-util-types "^2.0.0"
+
+micromark-util-encode@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8"
+ integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==
+
+micromark-util-sanitize-uri@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7"
+ integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==
+ dependencies:
+ micromark-util-character "^2.0.0"
+ micromark-util-encode "^2.0.0"
+ micromark-util-symbol "^2.0.0"
+
+micromark-util-symbol@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8"
+ integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==
+
+micromark-util-types@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e"
+ integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==
+
micromatch@4.0.5, micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
@@ -12989,6 +13360,11 @@ moment@^2.10.2, moment@^2.16.0, moment@^2.21.0, moment@^2.24.0, moment@^2.29.1,
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
+moo@^0.5.0:
+ version "0.5.2"
+ resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c"
+ integrity sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==
+
mri@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
@@ -13095,6 +13471,16 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
+nearley@^2.20.1:
+ version "2.20.1"
+ resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474"
+ integrity sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==
+ dependencies:
+ commander "^2.19.0"
+ moo "^0.5.0"
+ railroad-diagrams "^1.0.0"
+ randexp "0.4.6"
+
negotiator@0.6.2:
version "0.6.2"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
@@ -13520,6 +13906,15 @@ onetime@^6.0.0:
dependencies:
mimic-fn "^4.0.0"
+oniguruma-to-es@^2.2.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/oniguruma-to-es/-/oniguruma-to-es-2.3.0.tgz#35ea9104649b7c05f3963c6b3b474d964625028b"
+ integrity sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g==
+ dependencies:
+ emoji-regex-xs "^1.0.0"
+ regex "^5.1.1"
+ regex-recursion "^5.1.1"
+
open@^8.0.4, open@^8.4.0:
version "8.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
@@ -14399,6 +14794,11 @@ property-expr@^2.0.4:
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.4.tgz#37b925478e58965031bb612ec5b3260f8241e910"
integrity sha512-sFPkHQjVKheDNnPvotjQmm3KD3uk1fWKUN7CrpdbwmUx3CrG3QiM8QpTSimvig5vTXmTvjz7+TDvXOI9+4rkcg==
+property-information@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.0.0.tgz#3508a6d6b0b8eb3ca6eb2c6623b164d2ed2ab112"
+ integrity sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==
+
proxy-addr@~2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
@@ -14442,6 +14842,11 @@ pumpify@^1.3.3:
inherits "^2.0.3"
pump "^2.0.0"
+punycode.js@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
+ integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
+
punycode@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
@@ -14509,11 +14914,24 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
+railroad-diagrams@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e"
+ integrity sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==
+
ramda@0.29.0:
version "0.29.0"
resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.29.0.tgz#fbbb67a740a754c8a4cbb41e2a6e0eb8507f55fb"
integrity sha512-BBea6L67bYLtdbOqfp8f58fPMqEwx0doL+pAi8TZyp2YWz8R9G8z9x75CZI8W+ftqhFHCpEX2cRnUUXK130iKA==
+randexp@0.4.6:
+ version "0.4.6"
+ resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3"
+ integrity sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==
+ dependencies:
+ discontinuous-range "1.0.0"
+ ret "~0.1.10"
+
randombytes@^2.0.3, randombytes@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
@@ -15019,6 +15437,26 @@ regex-parser@^2.2.11:
resolved "https://registry.yarnpkg.com/regex-parser/-/regex-parser-2.2.11.tgz#3b37ec9049e19479806e878cabe7c1ca83ccfe58"
integrity sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==
+regex-recursion@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/regex-recursion/-/regex-recursion-5.1.1.tgz#5a73772d18adbf00f57ad097bf54171b39d78f8b"
+ integrity sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==
+ dependencies:
+ regex "^5.1.1"
+ regex-utilities "^2.3.0"
+
+regex-utilities@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/regex-utilities/-/regex-utilities-2.3.0.tgz#87163512a15dce2908cf079c8960d5158ff43280"
+ integrity sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==
+
+regex@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/regex/-/regex-5.1.1.tgz#cf798903f24d6fe6e531050a36686e082b29bd03"
+ integrity sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==
+ dependencies:
+ regex-utilities "^2.3.0"
+
regexp.prototype.flags@^1.4.3, regexp.prototype.flags@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz#fe7ce25e7e4cca8db37b6634c8a2c7009199b9cb"
@@ -15228,6 +15666,11 @@ restore-cursor@^4.0.0:
onetime "^5.1.0"
signal-exit "^3.0.2"
+ret@~0.1.10:
+ version "0.1.15"
+ resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
+ integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
+
retry@^0.13.1:
version "0.13.1"
resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658"
@@ -15604,6 +16047,20 @@ shellwords@^0.1.1:
resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==
+shiki@1.29.2, shiki@^1.22.2:
+ version "1.29.2"
+ resolved "https://registry.yarnpkg.com/shiki/-/shiki-1.29.2.tgz#5c93771f2d5305ce9c05975c33689116a27dc657"
+ integrity sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==
+ dependencies:
+ "@shikijs/core" "1.29.2"
+ "@shikijs/engine-javascript" "1.29.2"
+ "@shikijs/engine-oniguruma" "1.29.2"
+ "@shikijs/langs" "1.29.2"
+ "@shikijs/themes" "1.29.2"
+ "@shikijs/types" "1.29.2"
+ "@shikijs/vscode-textmate" "^10.0.1"
+ "@types/hast" "^3.0.4"
+
should-equal@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/should-equal/-/should-equal-2.0.0.tgz#6072cf83047360867e68e98b09d71143d04ee0c3"
@@ -15735,6 +16192,13 @@ slice-ansi@^5.0.0:
ansi-styles "^6.0.0"
is-fullwidth-code-point "^4.0.0"
+smtp-address-parser@1.0.10:
+ version "1.0.10"
+ resolved "https://registry.yarnpkg.com/smtp-address-parser/-/smtp-address-parser-1.0.10.tgz#9fc4ed6021f13dc3d8f591e0ad0d50454073025e"
+ integrity sha512-Osg9LmvGeAG/hyao4mldbflLOkkr3a+h4m1lwKCK5U8M6ZAr7tdXEz/+/vr752TSGE4MNUlUl9cIK2cB8cgzXg==
+ dependencies:
+ nearley "^2.20.1"
+
snake-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c"
@@ -15799,6 +16263,11 @@ space-separated-tokens@^1.0.0:
resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899"
integrity sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==
+space-separated-tokens@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f"
+ integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==
+
spdx-correct@^3.0.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.2.0.tgz#4f5ab0668f0059e34f9c00dce331784a12de4e9c"
@@ -16068,6 +16537,14 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
+stringify-entities@^4.0.0:
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3"
+ integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==
+ dependencies:
+ character-entities-html4 "^2.0.0"
+ character-entities-legacy "^3.0.0"
+
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
@@ -16612,6 +17089,11 @@ tr46@~0.0.3:
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
+trim-lines@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338"
+ integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==
+
triple-beam@^1.2.0, triple-beam@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
@@ -16796,6 +17278,11 @@ typescript@^5.5.2:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.2.tgz#c26f023cb0054e657ce04f72583ea2d85f8d0507"
integrity sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==
+uc.micro@^2.0.0, uc.micro@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
+ integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
+
uglify-js@3.4.x:
version "3.4.10"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f"
@@ -16884,6 +17371,27 @@ unist-util-is@^4.0.0:
resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-4.1.0.tgz#976e5f462a7a5de73d94b706bac1b90671b57797"
integrity sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==
+unist-util-is@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424"
+ integrity sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==
+ dependencies:
+ "@types/unist" "^3.0.0"
+
+unist-util-position@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4"
+ integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==
+ dependencies:
+ "@types/unist" "^3.0.0"
+
+unist-util-stringify-position@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2"
+ integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==
+ dependencies:
+ "@types/unist" "^3.0.0"
+
unist-util-visit-parents@^3.0.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz#65a6ce698f78a6b0f56aa0e88f13801886cdaef6"
@@ -16892,6 +17400,14 @@ unist-util-visit-parents@^3.0.0:
"@types/unist" "^2.0.0"
unist-util-is "^4.0.0"
+unist-util-visit-parents@^6.0.0:
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815"
+ integrity sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ unist-util-is "^6.0.0"
+
unist-util-visit@^2.0.0:
version "2.0.3"
resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-2.0.3.tgz#c3703893146df47203bb8a9795af47d7b971208c"
@@ -16901,6 +17417,15 @@ unist-util-visit@^2.0.0:
unist-util-is "^4.0.0"
unist-util-visit-parents "^3.0.0"
+unist-util-visit@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6"
+ integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ unist-util-is "^6.0.0"
+ unist-util-visit-parents "^6.0.0"
+
universalify@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"
@@ -17086,6 +17611,11 @@ v@^0.3.0:
optionalDependencies:
deasync "^0.1.9"
+valid-url@^1.0.9:
+ version "1.0.9"
+ resolved "https://registry.yarnpkg.com/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200"
+ integrity sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==
+
validate-npm-package-license@^3.0.1:
version "3.0.4"
resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
@@ -17109,6 +17639,22 @@ vary@~1.1.2:
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+vfile-message@^4.0.0:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181"
+ integrity sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ unist-util-stringify-position "^4.0.0"
+
+vfile@^6.0.0:
+ version "6.0.3"
+ resolved "https://registry.yarnpkg.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab"
+ integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==
+ dependencies:
+ "@types/unist" "^3.0.0"
+ vfile-message "^4.0.0"
+
vite-node@2.1.8:
version "2.1.8"
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-2.1.8.tgz#9495ca17652f6f7f95ca7c4b568a235e0c8dbac5"
@@ -17742,6 +18288,11 @@ yaml@^1.10.0, yaml@^1.10.2:
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
+yaml@^2.3.4:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.7.0.tgz#aef9bb617a64c937a9a748803786ad8d3ffe1e98"
+ integrity sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==
+
yargs-parser@^18.1.2:
version "18.1.3"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
@@ -17859,3 +18410,8 @@ zustand@^4.1.1:
integrity sha512-h4F3WMqsZgvvaE0n3lThx4MM81Ls9xebjvrABNzf5+jb3/03YjNTSgZXeyrvXDArMeV9untvWXRw1tY+ntPYbA==
dependencies:
use-sync-external-store "1.2.0"
+
+zwitch@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
+ integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==
From 7c01f84a5c4b4f6f54b5d3debf83fcdbaa40fd66 Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Fri, 28 Mar 2025 10:52:59 +1300
Subject: [PATCH 066/212] fix: improve the node view for detecting roles -
develop (#354)
---
.../cluster/HomeView/NodesDatatable/utils.ts | 27 +++++++------------
1 file changed, 10 insertions(+), 17 deletions(-)
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/utils.ts b/app/react/kubernetes/cluster/HomeView/NodesDatatable/utils.ts
index a5d4b0b4d..05bac7ce0 100644
--- a/app/react/kubernetes/cluster/HomeView/NodesDatatable/utils.ts
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/utils.ts
@@ -6,36 +6,29 @@ export function getInternalNodeIpAddress(node?: Node) {
)?.address;
}
-// most kube clusters set control-plane label, older clusters set master, microk8s doesn't have either but instead sets microk8s-controlplane
const controlPlaneLabels = [
'node-role.kubernetes.io/control-plane',
'node-role.kubernetes.io/master',
- 'node.kubernetes.io/microk8s-controlplane',
+ 'node.kubernetes.io/microk8s-controlplane'
];
-const roleLabels = ['kubernetes.io/role'];
+const roleLabels = [
+ 'kubernetes.io/role',
+ 'node.kubernetes.io/role'
+];
-/**
- * Returns the role of the node based on the labels.
- * @param node The node to get the role of.
- * It uses similar logic to https://github.com/kubernetes/kubectl/blob/04bb64c802171066ed0d886c437590c0b7ff1ed3/pkg/describe/describe.go#L5523C1-L5541C2 ,
- * but only returns 'Control plane' or 'Worker'. It also has an additional check for microk8s.
- */
export function getRole(node: Node): 'Control plane' | 'Worker' {
const hasControlPlaneLabel = controlPlaneLabels.some(
- (label) =>
- // the label can be set to an empty string, so we need to check for undefined
- // e.g. node-role.kubernetes.io/control-plane: ""
- node.metadata?.labels?.[label] !== undefined
+ (label) => node.metadata?.labels?.[label] !== undefined
);
+
const hasControlPlaneLabelValue = roleLabels.some(
(label) =>
node.metadata?.labels?.[label] === 'control-plane' ||
node.metadata?.labels?.[label] === 'master'
);
- if (hasControlPlaneLabel || hasControlPlaneLabelValue) {
- return 'Control plane';
- }
- return 'Worker';
+ return hasControlPlaneLabel || hasControlPlaneLabelValue
+ ? 'Control plane'
+ : 'Worker';
}
From 1d12011eb579f04c338cdce136de389ff0c32384 Mon Sep 17 00:00:00 2001
From: Viktor Pettersson
Date: Fri, 28 Mar 2025 15:16:05 +0100
Subject: [PATCH 067/212] fix(edge groups): make large edge groups editable
[BE-11720] (#558)
---
api/http/handler/endpoints/endpoint_list.go | 3 +
api/http/handler/endpoints/filter.go | 86 +++++++++++++
app/edge/react/components/index.ts | 10 ++
.../AssociatedEdgeEnvironmentsSelector.tsx | 13 +-
...ssociatedEdgeGroupEnvironmentsSelector.tsx | 116 ++++++++++++++++++
.../EdgeEnvironmentsAssociationTable.tsx | 77 ++++++++++++
.../components/EdgeGroupAssociationTable.tsx | 62 +++++-----
.../associationTableColumnHelper.ts | 30 +++++
.../EdgeGroupForm/EdgeGroupForm.tsx | 2 +
.../EdgeGroupForm/StaticGroupFieldset.tsx | 5 +-
.../components/EdgeGroupForm/types.tsx | 6 +-
.../EdgeGroupForm/useValidation.tsx | 1 +
.../cluster/HomeView/NodesDatatable/utils.ts | 7 +-
.../environments/environment.service/index.ts | 1 +
14 files changed, 373 insertions(+), 46 deletions(-)
create mode 100644 app/react/edge/components/AssociatedEdgeGroupEnvironmentsSelector.tsx
create mode 100644 app/react/edge/components/EdgeEnvironmentsAssociationTable.tsx
create mode 100644 app/react/edge/components/associationTableColumnHelper.ts
diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go
index 6be1c7ff2..86f1b1d3c 100644
--- a/api/http/handler/endpoints/endpoint_list.go
+++ b/api/http/handler/endpoints/endpoint_list.go
@@ -38,6 +38,7 @@ const (
// @param tagIds query []int false "search environments(endpoints) with these tags (depends on tagsPartialMatch)"
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
// @param endpointIds query []int false "will return only these environments(endpoints)"
+// @param excludeIds query []int false "will exclude these environments(endpoints)"
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
// @param agentVersions query []string false "will return only environments with on of these agent versions"
// @param edgeAsync query bool false "if exists true show only edge async agents, false show only standard edge agents. if missing, will show both types (relevant only for edge agents)"
@@ -48,6 +49,8 @@ const (
// @param name query string false "will return only environments(endpoints) with this name"
// @param edgeStackId query portainer.EdgeStackID false "will return the environements of the specified edge stack"
// @param edgeStackStatus query string false "only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!)" Enum("Pending", "Ok", "Error", "Acknowledged", "Remove", "RemoteUpdateSuccess", "ImagesPulled")
+// @param edgeGroupIds query []int false "List environments(endpoints) of these edge groups"
+// @param excludeEdgeGroupIds query []int false "Exclude environments(endpoints) of these edge groups"
// @success 200 {array} portainer.Endpoint "Endpoints"
// @failure 500 "Server error"
// @router /endpoints [get]
diff --git a/api/http/handler/endpoints/filter.go b/api/http/handler/endpoints/filter.go
index df230f99e..6dc41b0bd 100644
--- a/api/http/handler/endpoints/filter.go
+++ b/api/http/handler/endpoints/filter.go
@@ -37,6 +37,8 @@ type EnvironmentsQuery struct {
edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType
excludeIds []portainer.EndpointID
+ edgeGroupIds []portainer.EdgeGroupID
+ excludeEdgeGroupIds []portainer.EdgeGroupID
}
func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
@@ -77,6 +79,16 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err
}
+ edgeGroupIDs, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "edgeGroupIds")
+ if err != nil {
+ return EnvironmentsQuery{}, err
+ }
+
+ excludeEdgeGroupIds, err := getNumberArrayQueryParameter[portainer.EdgeGroupID](r, "excludeEdgeGroupIds")
+ if err != nil {
+ return EnvironmentsQuery{}, err
+ }
+
agentVersions := getArrayQueryParameter(r, "agentVersions")
name, _ := request.RetrieveQueryParameter(r, "name", true)
@@ -117,6 +129,8 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
edgeCheckInPassedSeconds: edgeCheckInPassedSeconds,
edgeStackId: portainer.EdgeStackID(edgeStackId),
edgeStackStatus: edgeStackStatus,
+ edgeGroupIds: edgeGroupIDs,
+ excludeEdgeGroupIds: excludeEdgeGroupIds,
}, nil
}
@@ -143,6 +157,14 @@ func (handler *Handler) filterEndpointsByQuery(
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
}
+ if len(query.edgeGroupIds) > 0 {
+ filteredEndpoints, edgeGroups = filterEndpointsByEdgeGroupIDs(filteredEndpoints, edgeGroups, query.edgeGroupIds)
+ }
+
+ if len(query.excludeEdgeGroupIds) > 0 {
+ filteredEndpoints, edgeGroups = filterEndpointsByExcludeEdgeGroupIDs(filteredEndpoints, edgeGroups, query.excludeEdgeGroupIds)
+ }
+
if query.name != "" {
filteredEndpoints = filterEndpointsByName(filteredEndpoints, query.name)
}
@@ -295,6 +317,70 @@ func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs
return endpoints[:n]
}
+func filterEndpointsByEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeGroupIDs []portainer.EdgeGroupID) ([]portainer.Endpoint, []portainer.EdgeGroup) {
+ edgeGroupIDFilterSet := make(map[portainer.EdgeGroupID]struct{}, len(edgeGroupIDs))
+ for _, id := range edgeGroupIDs {
+ edgeGroupIDFilterSet[id] = struct{}{}
+ }
+
+ n := 0
+ for _, edgeGroup := range edgeGroups {
+ if _, exists := edgeGroupIDFilterSet[edgeGroup.ID]; exists {
+ edgeGroups[n] = edgeGroup
+ n++
+ }
+ }
+ edgeGroups = edgeGroups[:n]
+
+ endpointIDSet := make(map[portainer.EndpointID]struct{})
+ for _, edgeGroup := range edgeGroups {
+ for _, endpointID := range edgeGroup.Endpoints {
+ endpointIDSet[endpointID] = struct{}{}
+ }
+ }
+
+ n = 0
+ for _, endpoint := range endpoints {
+ if _, exists := endpointIDSet[endpoint.ID]; exists {
+ endpoints[n] = endpoint
+ n++
+ }
+ }
+
+ return endpoints[:n], edgeGroups
+}
+
+func filterEndpointsByExcludeEdgeGroupIDs(endpoints []portainer.Endpoint, edgeGroups []portainer.EdgeGroup, excludeEdgeGroupIds []portainer.EdgeGroupID) ([]portainer.Endpoint, []portainer.EdgeGroup) {
+ excludeEdgeGroupIDSet := make(map[portainer.EdgeGroupID]struct{}, len(excludeEdgeGroupIds))
+ for _, id := range excludeEdgeGroupIds {
+ excludeEdgeGroupIDSet[id] = struct{}{}
+ }
+
+ n := 0
+ excludeEndpointIDSet := make(map[portainer.EndpointID]struct{})
+ for _, edgeGroup := range edgeGroups {
+ if _, ok := excludeEdgeGroupIDSet[edgeGroup.ID]; ok {
+ for _, endpointID := range edgeGroup.Endpoints {
+ excludeEndpointIDSet[endpointID] = struct{}{}
+ }
+ } else {
+ edgeGroups[n] = edgeGroup
+ n++
+ }
+ }
+ edgeGroups = edgeGroups[:n]
+
+ n = 0
+ for _, endpoint := range endpoints {
+ if _, ok := excludeEndpointIDSet[endpoint.ID]; !ok {
+ endpoints[n] = endpoint
+ n++
+ }
+ }
+
+ return endpoints[:n], edgeGroups
+}
+
func filterEndpointsBySearchCriteria(
endpoints []portainer.Endpoint,
endpointGroups []portainer.EndpointGroup,
diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts
index b4913e51c..1902af60a 100644
--- a/app/edge/react/components/index.ts
+++ b/app/edge/react/components/index.ts
@@ -8,6 +8,7 @@ import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncInterva
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
+import { AssociatedEdgeGroupEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeGroupEnvironmentsSelector';
const ngModule = angular
.module('portainer.edge.react.components', [])
@@ -61,6 +62,15 @@ const ngModule = angular
'value',
'error',
])
+ )
+ .component(
+ 'associatedEdgeGroupEnvironmentsSelector',
+ r2a(withReactQuery(AssociatedEdgeGroupEnvironmentsSelector), [
+ 'onChange',
+ 'value',
+ 'error',
+ 'edgeGroupId',
+ ])
);
export const componentsModule = ngModule.name;
diff --git a/app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx b/app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx
index 7c8bce1d5..1fdd0e30c 100644
--- a/app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx
+++ b/app/react/edge/components/AssociatedEdgeEnvironmentsSelector.tsx
@@ -1,10 +1,9 @@
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
+import { EdgeEnvironmentsAssociationTable } from '@/react/edge/components/EdgeEnvironmentsAssociationTable';
import { FormError } from '@@/form-components/FormError';
import { ArrayError } from '@@/form-components/InputList/InputList';
-import { EdgeGroupAssociationTable } from './EdgeGroupAssociationTable';
-
export function AssociatedEdgeEnvironmentsSelector({
onChange,
value,
@@ -20,9 +19,9 @@ export function AssociatedEdgeEnvironmentsSelector({
return (
<>
- You can select which environment should be part of this group by moving
- them to the associated environments table. Simply click on any
- environment entry to move it from one table to the other.
+ You can also select environments individually by moving them to the
+ associated environments table. Simply click on any environment entry to
+ move it from one table to the other.
{error && (
@@ -36,7 +35,7 @@ export function AssociatedEdgeEnvironmentsSelector({
-
-
void;
+ value: EnvironmentId[];
+ error?: ArrayError>;
+ edgeGroupId?: EdgeGroupId;
+}) {
+ const [associatedEnvironments, setAssociatedEnvironments] = useState<
+ Environment[]
+ >([]);
+ const [dissociatedEnvironments, setDissociatedEnvironments] = useState<
+ Environment[]
+ >([]);
+
+ function updateEditedEnvironments(env: Environment) {
+ // If the env is associated, this update is a dissociation
+ const isAssociated = value.includes(env.Id);
+
+ setAssociatedEnvironments((prev) =>
+ isAssociated
+ ? prev.filter((prevEnv) => prevEnv.Id !== env.Id)
+ : [...prev, env]
+ );
+
+ setDissociatedEnvironments((prev) =>
+ isAssociated
+ ? [...prev, env]
+ : prev.filter((prevEnv) => prevEnv.Id !== env.Id)
+ );
+
+ const updatedValue = isAssociated
+ ? value.filter((id) => id !== env.Id)
+ : [...value, env.Id];
+
+ onChange(updatedValue, {
+ type: isAssociated ? 'remove' : 'add',
+ value: env.Id,
+ });
+ }
+
+ return (
+ <>
+
+ You can select which environment should be part of this group by moving
+ them to the associated environments table. Simply click on any
+ environment entry to move it from one table to the other.
+
+
+ {error && (
+
+
+ {typeof error === 'string' ? error : error.join(', ')}
+
+
+ )}
+
+
+
+
+ {
+ if (!value.includes(env.Id)) {
+ updateEditedEnvironments(env);
+ }
+ }}
+ data-cy="edgeGroupCreate-availableEndpoints"
+ />
+
+
+ {
+ if (value.includes(env.Id)) {
+ updateEditedEnvironments(env);
+ }
+ }}
+ data-cy="edgeGroupCreate-associatedEndpointsTable"
+ />
+
+
+
+ >
+ );
+}
diff --git a/app/react/edge/components/EdgeEnvironmentsAssociationTable.tsx b/app/react/edge/components/EdgeEnvironmentsAssociationTable.tsx
new file mode 100644
index 000000000..d7d87ec51
--- /dev/null
+++ b/app/react/edge/components/EdgeEnvironmentsAssociationTable.tsx
@@ -0,0 +1,77 @@
+import { useMemo, useState } from 'react';
+
+import { useEnvironmentList } from '@/react/portainer/environments/queries';
+import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
+import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
+import { useTags } from '@/portainer/tags/queries';
+import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
+import { AutomationTestingProps } from '@/types';
+
+import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
+import { Datatable, TableRow } from '@@/datatables';
+
+import { columns, DecoratedEnvironment } from './associationTableColumnHelper';
+
+export function EdgeEnvironmentsAssociationTable({
+ title,
+ query,
+ onClickRow = () => {},
+ 'data-cy': dataCy,
+}: {
+ title: string;
+ query: EnvironmentsQueryParams;
+ onClickRow?: (env: Environment) => void;
+} & AutomationTestingProps) {
+ const tableState = useTableStateWithoutStorage('Name');
+ const [page, setPage] = useState(0);
+ const environmentsQuery = useEnvironmentList({
+ pageLimit: tableState.pageSize,
+ page: page + 1,
+ search: tableState.search,
+ sort: tableState.sortBy?.id as 'Group' | 'Name',
+ order: tableState.sortBy?.desc ? 'desc' : 'asc',
+ types: EdgeTypes,
+ ...query,
+ });
+ const groupsQuery = useGroups({
+ enabled: environmentsQuery.environments.length > 0,
+ });
+ const tagsQuery = useTags({
+ enabled: environmentsQuery.environments.length > 0,
+ });
+
+ const memoizedEnvironments: Array = useMemo(
+ () =>
+ environmentsQuery.environments.map((env) => ({
+ ...env,
+ Group: groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
+ Tags: env.TagIds.map(
+ (tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
+ ),
+ })),
+ [environmentsQuery.environments, groupsQuery.data, tagsQuery.data]
+ );
+
+ const { totalCount } = environmentsQuery;
+
+ return (
+
+ title={title}
+ columns={columns}
+ settingsManager={tableState}
+ dataset={memoizedEnvironments}
+ isServerSidePagination
+ page={page}
+ onPageChange={setPage}
+ totalCount={totalCount}
+ renderRow={(row) => (
+
+ cells={row.getVisibleCells()}
+ onClick={() => onClickRow(row.original)}
+ />
+ )}
+ data-cy={dataCy}
+ disableSelect
+ />
+ );
+}
diff --git a/app/react/edge/components/EdgeGroupAssociationTable.tsx b/app/react/edge/components/EdgeGroupAssociationTable.tsx
index 70ff66a08..1f58950ca 100644
--- a/app/react/edge/components/EdgeGroupAssociationTable.tsx
+++ b/app/react/edge/components/EdgeGroupAssociationTable.tsx
@@ -1,52 +1,32 @@
-import { createColumnHelper } from '@tanstack/react-table';
-import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import { useTags } from '@/portainer/tags/queries';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
-import { Environment } from '@/react/portainer/environments/types';
+import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
import { AutomationTestingProps } from '@/types';
+import {
+ columns,
+ DecoratedEnvironment,
+} from '@/react/edge/components/associationTableColumnHelper';
import { Datatable, TableRow } from '@@/datatables';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
-type DecoratedEnvironment = Environment & {
- Tags: string[];
- Group: string;
-};
-
-const columHelper = createColumnHelper();
-
-const columns = [
- columHelper.accessor('Name', {
- header: 'Name',
- id: 'Name',
- cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
- }),
- columHelper.accessor('Group', {
- header: 'Group',
- id: 'Group',
- cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
- }),
- columHelper.accessor((row) => row.Tags.join(','), {
- header: 'Tags',
- id: 'tags',
- enableSorting: false,
- cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
- }),
-];
-
export function EdgeGroupAssociationTable({
title,
query,
onClickRow = () => {},
+ addEnvironments = [],
+ excludeEnvironments = [],
'data-cy': dataCy,
}: {
title: string;
query: EnvironmentsQueryParams;
onClickRow?: (env: Environment) => void;
+ addEnvironments?: Environment[];
+ excludeEnvironments?: Environment[];
} & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(0);
@@ -56,8 +36,11 @@ export function EdgeGroupAssociationTable({
search: tableState.search,
sort: tableState.sortBy?.id as 'Group' | 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
+ types: EdgeTypes,
+ excludeIds: excludeEnvironments?.map((env) => env.Id),
...query,
});
+
const groupsQuery = useGroups({
enabled: environmentsQuery.environments.length > 0,
});
@@ -65,7 +48,7 @@ export function EdgeGroupAssociationTable({
enabled: environmentsQuery.environments.length > 0,
});
- const environments: Array = useMemo(
+ const memoizedEnvironments: Array = useMemo(
() =>
environmentsQuery.environments.map((env) => ({
...env,
@@ -79,12 +62,29 @@ export function EdgeGroupAssociationTable({
const { totalCount } = environmentsQuery;
+ const memoizedAddEnvironments: Array = useMemo(
+ () =>
+ addEnvironments.map((env) => ({
+ ...env,
+ Group: groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
+ Tags: env.TagIds.map(
+ (tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
+ ),
+ })),
+ [addEnvironments, groupsQuery.data, tagsQuery.data]
+ );
+
+ // Filter out environments that are already in the table, this is to prevent duplicates, which can happen when an environment is associated and then disassociated
+ const filteredAddEnvironments = memoizedAddEnvironments.filter(
+ (env) => !memoizedEnvironments.some((e) => e.Id === env.Id)
+ );
+
return (
title={title}
columns={columns}
settingsManager={tableState}
- dataset={environments}
+ dataset={memoizedEnvironments.concat(filteredAddEnvironments)}
isServerSidePagination
page={page}
onPageChange={setPage}
diff --git a/app/react/edge/components/associationTableColumnHelper.ts b/app/react/edge/components/associationTableColumnHelper.ts
new file mode 100644
index 000000000..6c83be77f
--- /dev/null
+++ b/app/react/edge/components/associationTableColumnHelper.ts
@@ -0,0 +1,30 @@
+import { createColumnHelper } from '@tanstack/react-table';
+import { truncate } from 'lodash';
+
+import { Environment } from '@/react/portainer/environments/types';
+
+export type DecoratedEnvironment = Environment & {
+ Tags: string[];
+ Group: string;
+};
+
+const columHelper = createColumnHelper();
+
+export const columns = [
+ columHelper.accessor('Name', {
+ header: 'Name',
+ id: 'Name',
+ cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
+ }),
+ columHelper.accessor('Group', {
+ header: 'Group',
+ id: 'Group',
+ cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
+ }),
+ columHelper.accessor((row) => row.Tags.join(','), {
+ header: 'Tags',
+ id: 'tags',
+ enableSorting: false,
+ cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
+ }),
+];
diff --git a/app/react/edge/edge-groups/components/EdgeGroupForm/EdgeGroupForm.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/EdgeGroupForm.tsx
index 1e401dfa7..7287875d8 100644
--- a/app/react/edge/edge-groups/components/EdgeGroupForm/EdgeGroupForm.tsx
+++ b/app/react/edge/edge-groups/components/EdgeGroupForm/EdgeGroupForm.tsx
@@ -35,6 +35,7 @@ export function EdgeGroupForm({
name: group.Name,
partialMatch: group.PartialMatch,
tagIds: group.TagIds,
+ edgeGroupId: group.Id,
}
: {
name: '',
@@ -42,6 +43,7 @@ export function EdgeGroupForm({
environmentIds: [],
partialMatch: false,
tagIds: [],
+ edgeGroupId: 0,
}
}
onSubmit={onSubmit}
diff --git a/app/react/edge/edge-groups/components/EdgeGroupForm/StaticGroupFieldset.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/StaticGroupFieldset.tsx
index bb333af7b..aecec7fc9 100644
--- a/app/react/edge/edge-groups/components/EdgeGroupForm/StaticGroupFieldset.tsx
+++ b/app/react/edge/edge-groups/components/EdgeGroupForm/StaticGroupFieldset.tsx
@@ -1,6 +1,6 @@
import { useFormikContext } from 'formik';
-import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
+import { AssociatedEdgeGroupEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeGroupEnvironmentsSelector';
import { FormSection } from '@@/form-components/FormSection';
import { confirmDestructive } from '@@/modals/confirm';
@@ -14,7 +14,7 @@ export function StaticGroupFieldset({ isEdit }: { isEdit?: boolean }) {
return (
-
{
@@ -33,6 +33,7 @@ export function StaticGroupFieldset({ isEdit }: { isEdit?: boolean }) {
setFieldValue('environmentIds', environmentIds);
}}
+ edgeGroupId={values.edgeGroupId}
/>
diff --git a/app/react/edge/edge-groups/components/EdgeGroupForm/types.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/types.tsx
index 2c4e0d6bf..1d67d9322 100644
--- a/app/react/edge/edge-groups/components/EdgeGroupForm/types.tsx
+++ b/app/react/edge/edge-groups/components/EdgeGroupForm/types.tsx
@@ -1,7 +1,11 @@
-import { EnvironmentId } from '@/react/portainer/environments/types';
+import {
+ EdgeGroupId,
+ EnvironmentId,
+} from '@/react/portainer/environments/types';
import { TagId } from '@/portainer/tags/types';
export interface FormValues {
+ edgeGroupId: EdgeGroupId;
name: string;
dynamic: boolean;
environmentIds: EnvironmentId[];
diff --git a/app/react/edge/edge-groups/components/EdgeGroupForm/useValidation.tsx b/app/react/edge/edge-groups/components/EdgeGroupForm/useValidation.tsx
index 7f4bc8284..436cd6888 100644
--- a/app/react/edge/edge-groups/components/EdgeGroupForm/useValidation.tsx
+++ b/app/react/edge/edge-groups/components/EdgeGroupForm/useValidation.tsx
@@ -21,6 +21,7 @@ export function useValidation({
is: true,
then: (schema) => schema.min(1, 'Tags are required'),
}),
+ edgeGroupId: number().default(0).notRequired(),
}),
[nameValidation]
);
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/utils.ts b/app/react/kubernetes/cluster/HomeView/NodesDatatable/utils.ts
index 05bac7ce0..d1450f404 100644
--- a/app/react/kubernetes/cluster/HomeView/NodesDatatable/utils.ts
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/utils.ts
@@ -9,13 +9,10 @@ export function getInternalNodeIpAddress(node?: Node) {
const controlPlaneLabels = [
'node-role.kubernetes.io/control-plane',
'node-role.kubernetes.io/master',
- 'node.kubernetes.io/microk8s-controlplane'
+ 'node.kubernetes.io/microk8s-controlplane',
];
-const roleLabels = [
- 'kubernetes.io/role',
- 'node.kubernetes.io/role'
-];
+const roleLabels = ['kubernetes.io/role', 'node.kubernetes.io/role'];
export function getRole(node: Node): 'Control plane' | 'Worker' {
const hasControlPlaneLabel = controlPlaneLabels.some(
diff --git a/app/react/portainer/environments/environment.service/index.ts b/app/react/portainer/environments/environment.service/index.ts
index c6d770e74..6d4e22d63 100644
--- a/app/react/portainer/environments/environment.service/index.ts
+++ b/app/react/portainer/environments/environment.service/index.ts
@@ -50,6 +50,7 @@ export interface BaseEnvironmentsQueryParams {
edgeCheckInPassedSeconds?: number;
platformTypes?: PlatformType[];
edgeGroupIds?: EdgeGroupId[];
+ excludeEdgeGroupIds?: EdgeGroupId[];
}
export type EnvironmentsQueryParams = BaseEnvironmentsQueryParams &
From a0d36cf87a91171b70c04f0e2f86ea12a0281914 Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Mon, 31 Mar 2025 18:58:20 -0300
Subject: [PATCH 068/212] fix(server): add panic logging middleware BE-11750
(#599)
---
api/http/middlewares/panic_logger.go | 25 +++++++++++++++++++++++++
api/http/server.go | 2 +-
2 files changed, 26 insertions(+), 1 deletion(-)
create mode 100644 api/http/middlewares/panic_logger.go
diff --git a/api/http/middlewares/panic_logger.go b/api/http/middlewares/panic_logger.go
new file mode 100644
index 000000000..6f3b2076f
--- /dev/null
+++ b/api/http/middlewares/panic_logger.go
@@ -0,0 +1,25 @@
+package middlewares
+
+import (
+ "net/http"
+ "runtime/debug"
+
+ "github.com/rs/zerolog/log"
+)
+
+func WithPanicLogger(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ defer func() {
+ if err := recover(); err != nil {
+ log.Error().
+ Any("panic", err).
+ Str("method", req.Method).
+ Str("url", req.URL.String()).
+ Str("stack", string(debug.Stack())).
+ Msg("Panic in request handler")
+ }
+ }()
+
+ next.ServeHTTP(w, req)
+ })
+}
diff --git a/api/http/server.go b/api/http/server.go
index 3dcc84d2d..12925d479 100644
--- a/api/http/server.go
+++ b/api/http/server.go
@@ -335,7 +335,7 @@ func (server *Server) Start() error {
handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler))
- handler = middlewares.WithSlowRequestsLogger(handler)
+ handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler))
handler, err := csrf.WithProtect(handler)
if err != nil {
From 4066a70ea55aad2cb7994d697a3a8612aae63032 Mon Sep 17 00:00:00 2001
From: Anthony Lapenna
Date: Tue, 1 Apr 2025 13:24:55 +1300
Subject: [PATCH 069/212] api: fix typo in operation name (#585)
---
api/http/handler/edgegroups/edgegroup_update.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go
index 319a1b03b..d6a03a7ef 100644
--- a/api/http/handler/edgegroups/edgegroup_update.go
+++ b/api/http/handler/edgegroups/edgegroup_update.go
@@ -35,7 +35,7 @@ func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error {
return nil
}
-// @id EgeGroupUpdate
+// @id EdgeGroupUpdate
// @summary Updates an EdgeGroup
// @description **Access policy**: administrator
// @tags edge_groups
From 1edc56c0ceb601d4eab3108ef485cee404ab99ea Mon Sep 17 00:00:00 2001
From: Anthony Lapenna
Date: Tue, 1 Apr 2025 13:25:09 +1300
Subject: [PATCH 070/212] api: remove name from edgegroupupdate payload
validation (#588)
---
api/http/handler/edgegroups/edgegroup_update.go | 4 ----
1 file changed, 4 deletions(-)
diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go
index d6a03a7ef..7831b634e 100644
--- a/api/http/handler/edgegroups/edgegroup_update.go
+++ b/api/http/handler/edgegroups/edgegroup_update.go
@@ -24,10 +24,6 @@ type edgeGroupUpdatePayload struct {
}
func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error {
- if len(payload.Name) == 0 {
- return errors.New("invalid Edge group name")
- }
-
if payload.Dynamic && len(payload.TagIDs) == 0 {
return errors.New("tagIDs is mandatory for a dynamic Edge group")
}
From 7e5db1f55e62126b927e6dfa8655b40f934fd4d5 Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Tue, 1 Apr 2025 14:05:56 +1300
Subject: [PATCH 071/212] refactor(edgegroup): optimize edge group search
performance [BE-11716] (#579)
---
api/internal/edge/edgegroup.go | 16 +++++++---------
1 file changed, 7 insertions(+), 9 deletions(-)
diff --git a/api/internal/edge/edgegroup.go b/api/internal/edge/edgegroup.go
index 255f5f55f..9a292b7e2 100644
--- a/api/internal/edge/edgegroup.go
+++ b/api/internal/edge/edgegroup.go
@@ -13,21 +13,19 @@ func EdgeGroupRelatedEndpoints(edgeGroup *portainer.EdgeGroup, endpoints []porta
return edgeGroup.Endpoints
}
+ endpointGroupsMap := map[portainer.EndpointGroupID]*portainer.EndpointGroup{}
+ for i, group := range endpointGroups {
+ endpointGroupsMap[group.ID] = &endpointGroups[i]
+ }
+
endpointIDs := []portainer.EndpointID{}
for _, endpoint := range endpoints {
if !endpointutils.IsEdgeEndpoint(&endpoint) {
continue
}
- var endpointGroup portainer.EndpointGroup
- for _, group := range endpointGroups {
- if endpoint.GroupID == group.ID {
- endpointGroup = group
- break
- }
- }
-
- if edgeGroupRelatedToEndpoint(edgeGroup, &endpoint, &endpointGroup) {
+ endpointGroup := endpointGroupsMap[endpoint.GroupID]
+ if edgeGroupRelatedToEndpoint(edgeGroup, &endpoint, endpointGroup) {
endpointIDs = append(endpointIDs, endpoint.ID)
}
}
From 4c1e80ff58cc88c27288efe4ee6ed86abb2c0abd Mon Sep 17 00:00:00 2001
From: Devon Steenberg
Date: Wed, 2 Apr 2025 08:51:58 +1300
Subject: [PATCH 072/212] fix(axios): correctly encode urls [BE-11648] (#517)
fix(edgegroup): nil pointer defer
---
api/internal/edge/edgegroup.go | 12 +--
app/portainer/services/axios.ts | 4 +
package.json | 1 +
yarn.lock | 145 ++++++++++++++++++++++++++++++++
4 files changed, 154 insertions(+), 8 deletions(-)
diff --git a/api/internal/edge/edgegroup.go b/api/internal/edge/edgegroup.go
index 9a292b7e2..64aa296a5 100644
--- a/api/internal/edge/edgegroup.go
+++ b/api/internal/edge/edgegroup.go
@@ -1,6 +1,8 @@
package edge
import (
+ "slices"
+
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -70,17 +72,11 @@ func GetEndpointsFromEdgeGroups(edgeGroupIDs []portainer.EdgeGroupID, datastore
// edgeGroupRelatedToEndpoint returns true if edgeGroup is associated with environment(endpoint)
func edgeGroupRelatedToEndpoint(edgeGroup *portainer.EdgeGroup, endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) bool {
if !edgeGroup.Dynamic {
- for _, endpointID := range edgeGroup.Endpoints {
- if endpoint.ID == endpointID {
- return true
- }
- }
-
- return false
+ return slices.Contains(edgeGroup.Endpoints, endpoint.ID)
}
endpointTags := tag.Set(endpoint.TagIDs)
- if endpointGroup.TagIDs != nil {
+ if endpointGroup != nil && endpointGroup.TagIDs != nil {
endpointTags = tag.Union(endpointTags, tag.Set(endpointGroup.TagIDs))
}
diff --git a/app/portainer/services/axios.ts b/app/portainer/services/axios.ts
index 7f7683cd1..d5c8d5840 100644
--- a/app/portainer/services/axios.ts
+++ b/app/portainer/services/axios.ts
@@ -12,6 +12,7 @@ import {
} from 'axios-cache-interceptor';
import { loadProgressBar } from 'axios-progress-bar';
import 'axios-progress-bar/dist/nprogress.css';
+import qs from 'qs';
import PortainerError from '@/portainer/error';
@@ -53,6 +54,9 @@ function headerInterpreter(
const axios = Axios.create({
baseURL: 'api',
maxDockerAPIVersion: MAX_DOCKER_API_VERSION,
+ paramsSerializer: {
+ serialize: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
+ },
});
axios.interceptors.request.use((req) => {
dispatchCacheRefreshEventIfNeeded(req);
diff --git a/package.json b/package.json
index 889d9e8da..b67cd56ff 100644
--- a/package.json
+++ b/package.json
@@ -111,6 +111,7 @@
"mustache": "^4.2.0",
"ng-file-upload": "~12.2.13",
"parse-duration": "^1.0.2",
+ "qs": "^6.14.0",
"rc-slider": "^10.0.0",
"react": "^17.0.2",
"react-calendar": "^4.8.0",
diff --git a/yarn.lock b/yarn.lock
index 638c32535..a041f9578 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8073,6 +8073,14 @@ cache-parser@1.2.4:
resolved "https://registry.yarnpkg.com/cache-parser/-/cache-parser-1.2.4.tgz#60975135ef2330e6a1d60895279d7237a2a9b398"
integrity sha512-O0KwuHuJnbHUrghHi2kGp0SxnWSIBXTYt7M8WVhW0kbPRUNUKoE/Of6e1rRD6AAxmfxFunKnt90yEK09D+sc5g==
+call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
+ integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
+ dependencies:
+ es-errors "^1.3.0"
+ function-bind "^1.1.2"
+
call-bind@^1.0.0, call-bind@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
@@ -8081,6 +8089,14 @@ call-bind@^1.0.0, call-bind@^1.0.2:
function-bind "^1.1.1"
get-intrinsic "^1.0.2"
+call-bound@^1.0.2:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
+ integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
+ dependencies:
+ call-bind-apply-helpers "^1.0.2"
+ get-intrinsic "^1.3.0"
+
call-me-maybe@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa"
@@ -9514,6 +9530,15 @@ dotenv@^8.2.0:
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b"
integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==
+dunder-proto@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
+ integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
+ dependencies:
+ call-bind-apply-helpers "^1.0.1"
+ es-errors "^1.3.0"
+ gopd "^1.2.0"
+
duplexer@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
@@ -9757,6 +9782,16 @@ es-abstract@^1.22.1:
unbox-primitive "^1.0.2"
which-typed-array "^1.1.11"
+es-define-property@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
+ integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
+
+es-errors@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
+ integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
+
es-get-iterator@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6"
@@ -9807,6 +9842,13 @@ es-module-lexer@^1.5.4:
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78"
integrity sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==
+es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1"
+ integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==
+ dependencies:
+ es-errors "^1.3.0"
+
es-set-tostringtag@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz#338d502f6f674301d710b80c8592de8a15f09cd8"
@@ -10893,6 +10935,11 @@ function-bind@^1.1.1:
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+function-bind@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
+ integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
+
function.prototype.name@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.5.tgz#cce0505fe1ffb80503e6f9e46cc64e46a12a9621"
@@ -10947,6 +10994,22 @@ get-intrinsic@^1.1.3:
has "^1.0.3"
has-symbols "^1.0.3"
+get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
+ integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
+ dependencies:
+ call-bind-apply-helpers "^1.0.2"
+ es-define-property "^1.0.1"
+ es-errors "^1.3.0"
+ es-object-atoms "^1.1.1"
+ function-bind "^1.1.2"
+ get-proto "^1.0.1"
+ gopd "^1.2.0"
+ has-symbols "^1.1.0"
+ hasown "^2.0.2"
+ math-intrinsics "^1.1.0"
+
get-nonce@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
@@ -10967,6 +11030,14 @@ get-port@^5.1.1:
resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193"
integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==
+get-proto@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
+ integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
+ dependencies:
+ dunder-proto "^1.0.1"
+ es-object-atoms "^1.0.0"
+
get-stream@^2.2.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-2.3.1.tgz#5f38f93f346009666ee0150a054167f91bdd95de"
@@ -11195,6 +11266,11 @@ gopd@^1.0.1:
dependencies:
get-intrinsic "^1.1.3"
+gopd@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
+ integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
+
graceful-fs@^4.1.10, graceful-fs@^4.2.6:
version "4.2.8"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
@@ -11305,6 +11381,11 @@ has-symbols@^1.0.2, has-symbols@^1.0.3:
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
+has-symbols@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
+ integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
+
has-tostringtag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
@@ -11319,6 +11400,13 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
+hasown@^2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
+ integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
+ dependencies:
+ function-bind "^1.1.2"
+
hast-util-to-html@^9.0.4:
version "9.0.5"
resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005"
@@ -13060,6 +13148,11 @@ markdown-to-jsx@^7.1.8:
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.2.0.tgz#e7b46b65955f6a04d48a753acd55874a14bdda4b"
integrity sha512-3l4/Bigjm4bEqjCR6Xr+d4DtM1X6vvtGsMGSjJYyep8RjjIvcWtrXBS8Wbfe1/P+atKNMccpsraESIaWVplzVg==
+math-intrinsics@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
+ integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
+
md5@^2.2.1:
version "2.3.0"
resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f"
@@ -13754,6 +13847,11 @@ object-inspect@^1.12.3, object-inspect@^1.9.0:
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
+object-inspect@^1.13.3:
+ version "1.13.4"
+ resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
+ integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
+
object-is@^1.0.1, object-is@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac"
@@ -14904,6 +15002,13 @@ qs@^6.11.2:
dependencies:
side-channel "^1.0.4"
+qs@^6.14.0:
+ version "6.14.0"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930"
+ integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==
+ dependencies:
+ side-channel "^1.1.0"
+
querystringify@^2.1.1:
version "2.2.0"
resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6"
@@ -16105,6 +16210,35 @@ should@^13.2.1:
should-type-adaptors "^1.0.1"
should-util "^1.0.0"
+side-channel-list@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
+ integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==
+ dependencies:
+ es-errors "^1.3.0"
+ object-inspect "^1.13.3"
+
+side-channel-map@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42"
+ integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==
+ dependencies:
+ call-bound "^1.0.2"
+ es-errors "^1.3.0"
+ get-intrinsic "^1.2.5"
+ object-inspect "^1.13.3"
+
+side-channel-weakmap@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea"
+ integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==
+ dependencies:
+ call-bound "^1.0.2"
+ es-errors "^1.3.0"
+ get-intrinsic "^1.2.5"
+ object-inspect "^1.13.3"
+ side-channel-map "^1.0.1"
+
side-channel@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
@@ -16114,6 +16248,17 @@ side-channel@^1.0.4:
get-intrinsic "^1.0.2"
object-inspect "^1.9.0"
+side-channel@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9"
+ integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==
+ dependencies:
+ es-errors "^1.3.0"
+ object-inspect "^1.13.3"
+ side-channel-list "^1.0.0"
+ side-channel-map "^1.0.1"
+ side-channel-weakmap "^1.0.2"
+
siginfo@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30"
From a5d857d5e794553431b058ad5c39b0fa7cbaa71c Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Thu, 3 Apr 2025 09:13:01 +1300
Subject: [PATCH 073/212] feat(docker): add --pull-limit-check-disabled cli
flag [BE-11739] (#581)
---
api/cli/cli.go | 1 +
api/cli/defaults.go | 33 ++++++++---------
api/cli/defaults_windows.go | 35 ++++++++++---------
api/cmd/portainer/main.go | 1 +
.../endpoints/endpoint_dockerhub_status.go | 7 ++++
api/http/handler/endpoints/handler.go | 27 +++++++-------
api/http/server.go | 2 ++
api/portainer.go | 3 ++
8 files changed, 63 insertions(+), 46 deletions(-)
diff --git a/api/cli/cli.go b/api/cli/cli.go
index 67005fddb..f6035f298 100644
--- a/api/cli/cli.go
+++ b/api/cli/cli.go
@@ -60,6 +60,7 @@ func CLIFlags() *portainer.CLIFlags {
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
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(),
}
}
diff --git a/api/cli/defaults.go b/api/cli/defaults.go
index ca426cdad..13aa2df93 100644
--- a/api/cli/defaults.go
+++ b/api/cli/defaults.go
@@ -4,20 +4,21 @@
package cli
const (
- defaultBindAddress = ":9000"
- defaultHTTPSBindAddress = ":9443"
- defaultTunnelServerAddress = "0.0.0.0"
- defaultTunnelServerPort = "8000"
- defaultDataDirectory = "/data"
- defaultAssetsDirectory = "./"
- defaultTLS = "false"
- defaultTLSSkipVerify = "false"
- defaultTLSCACertPath = "/certs/ca.pem"
- defaultTLSCertPath = "/certs/cert.pem"
- defaultTLSKeyPath = "/certs/key.pem"
- defaultHTTPDisabled = "false"
- defaultHTTPEnabled = "false"
- defaultSSL = "false"
- defaultBaseURL = "/"
- defaultSecretKeyName = "portainer"
+ defaultBindAddress = ":9000"
+ defaultHTTPSBindAddress = ":9443"
+ defaultTunnelServerAddress = "0.0.0.0"
+ defaultTunnelServerPort = "8000"
+ defaultDataDirectory = "/data"
+ defaultAssetsDirectory = "./"
+ defaultTLS = "false"
+ defaultTLSSkipVerify = "false"
+ defaultTLSCACertPath = "/certs/ca.pem"
+ defaultTLSCertPath = "/certs/cert.pem"
+ defaultTLSKeyPath = "/certs/key.pem"
+ defaultHTTPDisabled = "false"
+ defaultHTTPEnabled = "false"
+ defaultSSL = "false"
+ defaultBaseURL = "/"
+ defaultSecretKeyName = "portainer"
+ defaultPullLimitCheckDisabled = "false"
)
diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go
index ed5996004..a9d02da65 100644
--- a/api/cli/defaults_windows.go
+++ b/api/cli/defaults_windows.go
@@ -1,21 +1,22 @@
package cli
const (
- defaultBindAddress = ":9000"
- defaultHTTPSBindAddress = ":9443"
- defaultTunnelServerAddress = "0.0.0.0"
- defaultTunnelServerPort = "8000"
- defaultDataDirectory = "C:\\data"
- defaultAssetsDirectory = "./"
- defaultTLS = "false"
- defaultTLSSkipVerify = "false"
- defaultTLSCACertPath = "C:\\certs\\ca.pem"
- defaultTLSCertPath = "C:\\certs\\cert.pem"
- defaultTLSKeyPath = "C:\\certs\\key.pem"
- defaultHTTPDisabled = "false"
- defaultHTTPEnabled = "false"
- defaultSSL = "false"
- defaultSnapshotInterval = "5m"
- defaultBaseURL = "/"
- defaultSecretKeyName = "portainer"
+ defaultBindAddress = ":9000"
+ defaultHTTPSBindAddress = ":9443"
+ defaultTunnelServerAddress = "0.0.0.0"
+ defaultTunnelServerPort = "8000"
+ defaultDataDirectory = "C:\\data"
+ defaultAssetsDirectory = "./"
+ defaultTLS = "false"
+ defaultTLSSkipVerify = "false"
+ defaultTLSCACertPath = "C:\\certs\\ca.pem"
+ defaultTLSCertPath = "C:\\certs\\cert.pem"
+ defaultTLSKeyPath = "C:\\certs\\key.pem"
+ defaultHTTPDisabled = "false"
+ defaultHTTPEnabled = "false"
+ defaultSSL = "false"
+ defaultSnapshotInterval = "5m"
+ defaultBaseURL = "/"
+ defaultSecretKeyName = "portainer"
+ defaultPullLimitCheckDisabled = "false"
)
diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go
index 068f31565..f16b3db0f 100644
--- a/api/cmd/portainer/main.go
+++ b/api/cmd/portainer/main.go
@@ -576,6 +576,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
AdminCreationDone: adminCreationDone,
PendingActionsService: pendingActionsService,
PlatformService: platformService,
+ PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
}
}
diff --git a/api/http/handler/endpoints/endpoint_dockerhub_status.go b/api/http/handler/endpoints/endpoint_dockerhub_status.go
index 7427a8750..c7d43149f 100644
--- a/api/http/handler/endpoints/endpoint_dockerhub_status.go
+++ b/api/http/handler/endpoints/endpoint_dockerhub_status.go
@@ -80,6 +80,13 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R
}
}
+ if handler.PullLimitCheckDisabled {
+ return response.JSON(w, &dockerhubStatusResponse{
+ Limit: 10,
+ Remaining: 10,
+ })
+ }
+
httpClient := client.NewHTTPClient()
token, err := getDockerHubToken(httpClient, registry)
if err != nil {
diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go
index d1e323e8f..0260acaf0 100644
--- a/api/http/handler/endpoints/handler.go
+++ b/api/http/handler/endpoints/handler.go
@@ -26,19 +26,20 @@ func hideFields(endpoint *portainer.Endpoint) {
// Handler is the HTTP handler used to handle environment(endpoint) operations.
type Handler struct {
*mux.Router
- requestBouncer security.BouncerService
- DataStore dataservices.DataStore
- FileService portainer.FileService
- ProxyManager *proxy.Manager
- ReverseTunnelService portainer.ReverseTunnelService
- SnapshotService portainer.SnapshotService
- K8sClientFactory *cli.ClientFactory
- ComposeStackManager portainer.ComposeStackManager
- AuthorizationService *authorization.Service
- DockerClientFactory *dockerclient.ClientFactory
- BindAddress string
- BindAddressHTTPS string
- PendingActionsService *pendingactions.PendingActionsService
+ requestBouncer security.BouncerService
+ DataStore dataservices.DataStore
+ FileService portainer.FileService
+ ProxyManager *proxy.Manager
+ ReverseTunnelService portainer.ReverseTunnelService
+ SnapshotService portainer.SnapshotService
+ K8sClientFactory *cli.ClientFactory
+ ComposeStackManager portainer.ComposeStackManager
+ AuthorizationService *authorization.Service
+ DockerClientFactory *dockerclient.ClientFactory
+ BindAddress string
+ BindAddressHTTPS string
+ PendingActionsService *pendingactions.PendingActionsService
+ PullLimitCheckDisabled bool
}
// NewHandler creates a handler to manage environment(endpoint) operations.
diff --git a/api/http/server.go b/api/http/server.go
index 12925d479..3b3fff77d 100644
--- a/api/http/server.go
+++ b/api/http/server.go
@@ -112,6 +112,7 @@ type Server struct {
AdminCreationDone chan struct{}
PendingActionsService *pendingactions.PendingActionsService
PlatformService platform.Service
+ PullLimitCheckDisabled bool
}
// Start starts the HTTP server
@@ -181,6 +182,7 @@ func (server *Server) Start() error {
endpointHandler.BindAddress = server.BindAddress
endpointHandler.BindAddressHTTPS = server.BindAddressHTTPS
endpointHandler.PendingActionsService = server.PendingActionsService
+ endpointHandler.PullLimitCheckDisabled = server.PullLimitCheckDisabled
var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer, server.DataStore, server.FileService, server.ReverseTunnelService)
diff --git a/api/portainer.go b/api/portainer.go
index 1f57b0ada..0c8b92b08 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -134,6 +134,7 @@ type (
LogLevel *string
LogMode *string
KubectlShellImage *string
+ PullLimitCheckDisabled *bool
}
// CustomTemplateVariableDefinition
@@ -1689,6 +1690,8 @@ const (
PortainerCacheHeader = "X-Portainer-Cache"
// KubectlShellImageEnvVar is the environment variable used to override the default kubectl shell image
KubectlShellImageEnvVar = "KUBECTL_SHELL_IMAGE"
+ // PullLimitCheckDisabledEnvVar is the environment variable used to disable the pull limit check
+ PullLimitCheckDisabledEnvVar = "PULL_LIMIT_CHECK_DISABLED"
)
// List of supported features
From 3800249921aac7d0c2bfbfbf19d8157ed07e49e0 Mon Sep 17 00:00:00 2001
From: Anthony Lapenna
Date: Thu, 3 Apr 2025 11:12:24 +1300
Subject: [PATCH 074/212] api: use response code 200 (#604)
---
api/http/handler/users/user_create_access_token.go | 4 ++--
api/http/handler/users/user_create_access_token_test.go | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/api/http/handler/users/user_create_access_token.go b/api/http/handler/users/user_create_access_token.go
index a2e63cf5c..673c0af76 100644
--- a/api/http/handler/users/user_create_access_token.go
+++ b/api/http/handler/users/user_create_access_token.go
@@ -50,7 +50,7 @@ type accessTokenResponse struct {
// @produce json
// @param id path int true "User identifier"
// @param body body userAccessTokenCreatePayload true "details"
-// @success 200 {object} accessTokenResponse "Created"
+// @success 200 {object} accessTokenResponse "Success"
// @failure 400 "Invalid request"
// @failure 401 "Unauthorized"
// @failure 403 "Permission denied"
@@ -115,7 +115,7 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Internal Server Error", err)
}
- return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusCreated)
+ return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusOK)
}
func (handler *Handler) usesInternalAuthentication(userid portainer.UserID) (bool, error) {
diff --git a/api/http/handler/users/user_create_access_token_test.go b/api/http/handler/users/user_create_access_token_test.go
index 5199366a2..b7e49d96b 100644
--- a/api/http/handler/users/user_create_access_token_test.go
+++ b/api/http/handler/users/user_create_access_token_test.go
@@ -60,7 +60,7 @@ func Test_userCreateAccessToken(t *testing.T) {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
- is.Equal(http.StatusCreated, rr.Code)
+ is.Equal(http.StatusOK, rr.Code)
body, err := io.ReadAll(rr.Body)
is.NoError(err, "ReadAll should not return error")
From f6f07f4690a429fa1e42a0058491ba6631289989 Mon Sep 17 00:00:00 2001
From: James Player
Date: Thu, 3 Apr 2025 14:18:31 +1300
Subject: [PATCH 075/212] improvement(kubernetes): right align tags in
datatables R8S-250 (#601)
Co-authored-by: testA113
---
app/react/components/Badge/ExternalBadge.tsx | 8 ++++++--
app/react/components/Badge/SystemBadge.tsx | 8 ++++++--
.../ListView/ApplicationsDatatable/columns.name.tsx | 6 +++++-
.../ListView/ApplicationsDatatable/columns.tsx | 2 +-
.../ListView/ApplicationsStacksDatatable/columns.tsx | 2 +-
.../cluster/HomeView/NodesDatatable/columns/name.tsx | 7 +++++--
.../NodeApplicationsDatatable/columns.name.tsx | 10 ++++++----
.../IngressClassDatatable/columns/name.tsx | 4 ++--
app/react/kubernetes/components/ExternalBadge.tsx | 5 -----
app/react/kubernetes/components/SystemBadge.tsx | 5 -----
.../ListView/ConfigMapsDatatable/columns/name.tsx | 9 ++++++---
.../configs/ListView/SecretsDatatable/columns/name.tsx | 10 ++++++----
.../ingresses/IngressDatatable/columns/name.tsx | 2 +-
.../ClusterRoleBindingsDatatable/columns/name.tsx | 2 +-
.../ClusterRolesDatatable/columns/name.tsx | 6 ++++--
.../JobsView/CronJobsDatatable/columns/name.tsx | 2 +-
.../JobsView/JobsDatatable/columns/name.tsx | 2 +-
.../RolesView/RoleBindingsDatatable/columns/name.tsx | 2 +-
.../RolesView/RolesDatatable/columns/name.tsx | 6 ++++--
.../ServiceAccountsDatatable/columns/name.tsx | 2 +-
.../namespaces/ListView/columns/useColumns.tsx | 10 +++-------
.../ServicesView/ServicesDatatable/columns/name.tsx | 7 ++++---
app/react/kubernetes/volumes/ListView/columns.name.tsx | 6 +++---
23 files changed, 68 insertions(+), 55 deletions(-)
delete mode 100644 app/react/kubernetes/components/ExternalBadge.tsx
delete mode 100644 app/react/kubernetes/components/SystemBadge.tsx
diff --git a/app/react/components/Badge/ExternalBadge.tsx b/app/react/components/Badge/ExternalBadge.tsx
index 5cf85b844..e49afd07a 100644
--- a/app/react/components/Badge/ExternalBadge.tsx
+++ b/app/react/components/Badge/ExternalBadge.tsx
@@ -1,5 +1,9 @@
import { Badge } from '@@/Badge';
-export function ExternalBadge() {
- return External ;
+export function ExternalBadge({ className }: { className?: string }) {
+ return (
+
+ External
+
+ );
}
diff --git a/app/react/components/Badge/SystemBadge.tsx b/app/react/components/Badge/SystemBadge.tsx
index e09b944ff..204d01bbd 100644
--- a/app/react/components/Badge/SystemBadge.tsx
+++ b/app/react/components/Badge/SystemBadge.tsx
@@ -1,5 +1,9 @@
import { Badge } from '@@/Badge';
-export function SystemBadge() {
- return System ;
+export function SystemBadge({ className }: { className?: string }) {
+ return (
+
+ System
+
+ );
}
diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.name.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.name.tsx
index e0801cb8f..e7ff26593 100644
--- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.name.tsx
+++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.name.tsx
@@ -43,7 +43,11 @@ function Cell({
)}
- {isSystem ? : !item.ApplicationOwner && }
+ {isSystem ? (
+
+ ) : (
+ !item.ApplicationOwner &&
+ )}
);
}
diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx
index f5473b954..0ece6ed2e 100644
--- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx
+++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/columns.tsx
@@ -31,7 +31,7 @@ function NamespaceCell({ row, getValue }: CellContext
) {
>
{value}
- {isSystem && }
+ {isSystem && }
);
}
diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/columns.tsx b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/columns.tsx
index 40358f277..833bad58c 100644
--- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/columns.tsx
+++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/columns.tsx
@@ -30,7 +30,7 @@ function NamespaceCell({ row, getValue }: CellContext
) {
>
{value}
- {isSystem && }
+ {isSystem && }
);
}
diff --git a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/name.tsx b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/name.tsx
index 1a0ddce53..cf1252919 100644
--- a/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/name.tsx
+++ b/app/react/kubernetes/cluster/HomeView/NodesDatatable/columns/name.tsx
@@ -34,8 +34,11 @@ function NameCell({
{nodeName}
- {node.isApi && api }
- {node.isPublishedNode && environment IP }
+
+
+ {node.isApi && api }
+ {node.isPublishedNode && environment IP }
+
);
}
diff --git a/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.name.tsx b/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.name.tsx
index e7c6ecacf..bcc246dee 100644
--- a/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.name.tsx
+++ b/app/react/kubernetes/cluster/NodeView/NodeApplicationsDatatable/columns.name.tsx
@@ -2,11 +2,11 @@ import { CellContext } from '@tanstack/react-table';
import { isExternalApplication } from '@/react/kubernetes/applications/utils';
import { useIsSystemNamespace } from '@/react/kubernetes/namespaces/queries/useIsSystemNamespace';
-import { ExternalBadge } from '@/react/kubernetes/components/ExternalBadge';
-import { SystemBadge } from '@/react/kubernetes/components/SystemBadge';
import { Application } from '@/react/kubernetes/applications/ListView/ApplicationsDatatable/types';
import { Link } from '@@/Link';
+import { SystemBadge } from '@@/Badge/SystemBadge';
+import { ExternalBadge } from '@@/Badge/ExternalBadge';
import { helper } from './columns.helper';
@@ -28,9 +28,11 @@ function Cell({ row: { original: item } }: CellContext) {
{isSystem ? (
-
+
) : (
- isExternalApplication({ metadata: item.Metadata }) &&
+ isExternalApplication({ metadata: item.Metadata }) && (
+
+ )
)}
);
diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx
index be22a5362..70a2dc765 100644
--- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx
+++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx
@@ -19,9 +19,9 @@ function NameCell({
const className = getValue();
return (
-
+
{className}
- {row.original.New && Newly detected }
+ {row.original.New && Newly detected }
);
}
diff --git a/app/react/kubernetes/components/ExternalBadge.tsx b/app/react/kubernetes/components/ExternalBadge.tsx
deleted file mode 100644
index cba18f3f1..000000000
--- a/app/react/kubernetes/components/ExternalBadge.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { Badge } from '@@/Badge';
-
-export function ExternalBadge() {
- return external ;
-}
diff --git a/app/react/kubernetes/components/SystemBadge.tsx b/app/react/kubernetes/components/SystemBadge.tsx
deleted file mode 100644
index 17552d755..000000000
--- a/app/react/kubernetes/components/SystemBadge.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { Badge } from '@@/Badge';
-
-export function SystemBadge() {
- return system ;
-}
diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/name.tsx b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/name.tsx
index 526e009fa..065994322 100644
--- a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/name.tsx
+++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/columns/name.tsx
@@ -55,9 +55,12 @@ function Cell({ row }: CellContext) {
>
{name}
- {isSystemConfigMap && }
- {!isSystemToken && !hasConfigurationOwner && }
- {!row.original.inUse && !isSystemConfigMap && }
+
+
+ {isSystemConfigMap && }
+ {!isSystemToken && !hasConfigurationOwner && }
+ {!row.original.inUse && !isSystemConfigMap && }
+
);
diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/name.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/name.tsx
index ce0e6033c..31c30e0bb 100644
--- a/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/name.tsx
+++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/columns/name.tsx
@@ -43,7 +43,7 @@ function Cell({ row }: CellContext
) {
return (
-
+
) {
>
{name}
- {isSystemSecret &&
}
- {!isSystemToken && !hasConfigurationOwner &&
}
- {!row.original.inUse && !isSystemSecret &&
}
+
+ {isSystemSecret && }
+ {!isSystemToken && !hasConfigurationOwner && }
+ {!row.original.inUse && !isSystemSecret && }
+
);
diff --git a/app/react/kubernetes/ingresses/IngressDatatable/columns/name.tsx b/app/react/kubernetes/ingresses/IngressDatatable/columns/name.tsx
index d8574c47a..33c3f546f 100644
--- a/app/react/kubernetes/ingresses/IngressDatatable/columns/name.tsx
+++ b/app/react/kubernetes/ingresses/IngressDatatable/columns/name.tsx
@@ -35,7 +35,7 @@ function Cell({ row, getValue }: CellContext
) {
{name}
- {row.original.IsSystem && }
+ {row.original.IsSystem && }
);
}
diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/name.tsx
index 45ebb750e..6b6d02da7 100644
--- a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/name.tsx
+++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRoleBindingsDatatable/columns/name.tsx
@@ -15,7 +15,7 @@ export const name = columnHelper.accessor(
cell: ({ row }) => (
{row.original.name}
- {row.original.isSystem && }
+ {row.original.isSystem && }
),
}
diff --git a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/name.tsx
index 53addd62e..e42129606 100644
--- a/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/name.tsx
+++ b/app/react/kubernetes/more-resources/ClusterRolesView/ClusterRolesDatatable/columns/name.tsx
@@ -17,8 +17,10 @@ export const name = columnHelper.accessor(
cell: ({ row }) => (
{row.original.name}
- {row.original.isSystem &&
}
- {row.original.isUnused &&
}
+
+ {row.original.isSystem && }
+ {row.original.isUnused && }
+
),
}
diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/name.tsx
index a3394f57e..09620b65e 100644
--- a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/name.tsx
+++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/name.tsx
@@ -16,7 +16,7 @@ export const name = columnHelper.accessor(
cell: ({ row }) => (
{row.original.Name}
- {row.original.IsSystem && }
+ {row.original.IsSystem && }
),
}
diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/name.tsx
index a3394f57e..09620b65e 100644
--- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/name.tsx
+++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/name.tsx
@@ -16,7 +16,7 @@ export const name = columnHelper.accessor(
cell: ({ row }) => (
{row.original.Name}
- {row.original.IsSystem && }
+ {row.original.IsSystem && }
),
}
diff --git a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/name.tsx
index 45ebb750e..6b6d02da7 100644
--- a/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/name.tsx
+++ b/app/react/kubernetes/more-resources/RolesView/RoleBindingsDatatable/columns/name.tsx
@@ -15,7 +15,7 @@ export const name = columnHelper.accessor(
cell: ({ row }) => (
{row.original.name}
- {row.original.isSystem && }
+ {row.original.isSystem && }
),
}
diff --git a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/name.tsx
index b73bc6180..16482e5ca 100644
--- a/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/name.tsx
+++ b/app/react/kubernetes/more-resources/RolesView/RolesDatatable/columns/name.tsx
@@ -20,8 +20,10 @@ export const name = columnHelper.accessor(
cell: ({ row }) => (
{row.original.name}
- {row.original.isSystem &&
}
- {row.original.isUnused &&
}
+
+ {row.original.isSystem && }
+ {row.original.isUnused && }
+
),
}
diff --git a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/name.tsx b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/name.tsx
index e4749af9b..7a551f78c 100644
--- a/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/name.tsx
+++ b/app/react/kubernetes/more-resources/ServiceAccountsView/ServiceAccountsDatatable/columns/name.tsx
@@ -16,7 +16,7 @@ export const name = columnHelper.accessor(
cell: ({ row }) => (
{row.original.name}
- {row.original.isSystem &&
}
+ {row.original.isSystem &&
}
),
}
diff --git a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx
index 0da487973..541c2315f 100644
--- a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx
+++ b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx
@@ -28,7 +28,7 @@ export function useColumns() {
const name = getValue();
return (
- <>
+
{name}
- {item.IsSystem && (
-
-
-
- )}
- >
+ {item.IsSystem && }
+
);
},
}),
diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/name.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/name.tsx
index 3a7183299..3dbc5dccc 100644
--- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/name.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/name.tsx
@@ -36,9 +36,10 @@ export const name = columnHelper.accessor(
{name}
- {row.original.IsSystem && }
-
- {isExternal && !row.original.IsSystem && }
+
+ {row.original.IsSystem && }
+ {isExternal && !row.original.IsSystem && }
+
);
diff --git a/app/react/kubernetes/volumes/ListView/columns.name.tsx b/app/react/kubernetes/volumes/ListView/columns.name.tsx
index f1fc25138..ff2e986a1 100644
--- a/app/react/kubernetes/volumes/ListView/columns.name.tsx
+++ b/app/react/kubernetes/volumes/ListView/columns.name.tsx
@@ -41,12 +41,12 @@ export function NameCell({
{item.PersistentVolumeClaim.Name}
{isSystem ? (
-
+
) : (
- <>
+
{item.PersistentVolumeClaim.IsExternal && }
{!isVolumeUsed(item) && }
- >
+
)}
);
From 1b8fbbe7d7649bd0c26d0a40bcdff66fabc5e52d Mon Sep 17 00:00:00 2001
From: Devon Steenberg
Date: Fri, 4 Apr 2025 09:07:35 +1300
Subject: [PATCH 076/212] fix(libstack): compose project working directory
[BE-11751] (#600)
---
pkg/libstack/compose/composeplugin.go | 4 ----
pkg/libstack/compose/composeplugin_test.go | 8 ++++++--
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/pkg/libstack/compose/composeplugin.go b/pkg/libstack/compose/composeplugin.go
index 17126cc21..07fed9dbe 100644
--- a/pkg/libstack/compose/composeplugin.go
+++ b/pkg/libstack/compose/composeplugin.go
@@ -312,10 +312,6 @@ func createProject(ctx context.Context, configFilepaths []string, options libsta
workingDir = filepath.Dir(configFilepaths[0])
}
- if options.WorkingDir != "" {
- workingDir = options.WorkingDir
- }
-
if options.ProjectDir != "" {
// When relative paths are used in the compose file, the project directory is used as the base path
workingDir = options.ProjectDir
diff --git a/pkg/libstack/compose/composeplugin_test.go b/pkg/libstack/compose/composeplugin_test.go
index df71c5d36..a7121eadf 100644
--- a/pkg/libstack/compose/composeplugin_test.go
+++ b/pkg/libstack/compose/composeplugin_test.go
@@ -991,10 +991,12 @@ func Test_createProject(t *testing.T) {
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: libstack.Options{
+ // Note that this is the execution working directory not the compose project working directory
+ // and so it has no affect on the created projects working directory
WorkingDir: "/something-totally-different",
ProjectName: projectName,
},
- expectedProject: expectedSimpleComposeProject("/something-totally-different", nil),
+ expectedProject: expectedSimpleComposeProject("", nil),
},
{
name: "Relative Working Directory",
@@ -1003,10 +1005,12 @@ func Test_createProject(t *testing.T) {
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: libstack.Options{
+ // Note that this is the execution working directory not the compose project working directory
+ // and so it has no affect on the created projects working directory
WorkingDir: "something-totally-different",
ProjectName: projectName,
},
- expectedProject: expectedSimpleComposeProject("something-totally-different", nil),
+ expectedProject: expectedSimpleComposeProject("", nil),
},
{
name: "Absolute Project Directory",
From 940bf990f97a5a5767ea128193294eeace29d2a9 Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Fri, 4 Apr 2025 11:56:42 +1300
Subject: [PATCH 077/212] fix(edgeconfig): add edge config file interpolation
info message on edge stack page [BE-11741] (#606)
---
.../RelativePathFieldset/RelativePathFieldset.tsx | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx b/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx
index 4b4dac139..3c143394a 100644
--- a/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx
+++ b/app/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset.tsx
@@ -10,6 +10,7 @@ import { TextTip } from '@@/Tip/TextTip';
import { FormControl } from '@@/form-components/FormControl';
import { Input, Select } from '@@/form-components/Input';
import { useDocsUrl } from '@@/PageHeader/ContextHelp';
+import { InsightsBox } from '@@/InsightsBox';
import { RelativePathModel, getPerDevConfigsFilterType } from './types';
@@ -135,6 +136,20 @@ export function RelativePathFieldset({
{value.SupportPerDeviceConfigs && (
<>
+
+ Files named ${PORTAINER_EDGE_ID}.env
{' '}
+ and/or ${PORTAINER_EDGE_GROUP}.env
{' '}
+ contained by the config folder will be loaded for compose
+ file interpolation.
+
+ }
+ header="GitOps Edge Configurations"
+ insightCloseId="edge-config-interpolation-info"
+ className="mb-3"
+ />
+
{pathTipSwarm}
From 0f10b8ba2bb1d433983cbf9878497c779a53b311 Mon Sep 17 00:00:00 2001
From: Anthony Lapenna
Date: Mon, 7 Apr 2025 11:25:23 +1200
Subject: [PATCH 078/212] api: update TeamInspect doc (#618)
---
api/http/handler/teams/team_inspect.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/api/http/handler/teams/team_inspect.go b/api/http/handler/teams/team_inspect.go
index ee92b7bc2..857636590 100644
--- a/api/http/handler/teams/team_inspect.go
+++ b/api/http/handler/teams/team_inspect.go
@@ -21,7 +21,6 @@ import (
// @produce json
// @param id path int true "Team identifier"
// @success 200 {object} portainer.Team "Success"
-// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Team not found"
From ad89df4d0d0ac446deadf1eea5389a588da0d2a4 Mon Sep 17 00:00:00 2001
From: LP B
Date: Mon, 7 Apr 2025 17:14:51 +0200
Subject: [PATCH 079/212] refactor(app): reword docker security features (#608)
---
.../docker-features-configuration.html | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.html b/app/docker/views/docker-features-configuration/docker-features-configuration.html
index dab4efa2d..8edd5843f 100644
--- a/app/docker/views/docker-features-configuration/docker-features-configuration.html
+++ b/app/docker/views/docker-features-configuration/docker-features-configuration.html
@@ -67,7 +67,7 @@
@@ -114,7 +114,7 @@
@@ -125,7 +125,7 @@
@@ -136,7 +136,7 @@
@@ -146,7 +146,7 @@
From 264ff5457b8fafcec44640af71bf9e2112b1a6be Mon Sep 17 00:00:00 2001
From: James Player
Date: Tue, 8 Apr 2025 12:51:36 +1200
Subject: [PATCH 080/212] chore(kubernetes): Migrate Helm Templates View to
React R8S-239 (#587)
---
.../helm-templates.controller.js | 207 ------------------
.../helm/helm-templates/helm-templates.html | 113 ----------
.../helm/helm-templates/helm-templates.js | 14 --
app/kubernetes/react/components/index.ts | 18 +-
app/kubernetes/views/deploy/deploy.html | 8 +-
.../views/deploy/deployController.js | 6 +-
app/react/components/modals/confirm.ts | 10 +
app/react/hooks/useCanExit.ts | 17 ++
.../helm/HelmTemplates}/HelmIcon.tsx | 0
.../helm/HelmTemplates/HelmTemplates.tsx | 55 +++++
.../HelmTemplates/HelmTemplatesList.test.tsx | 10 +-
.../helm/HelmTemplates/HelmTemplatesList.tsx | 8 +-
.../HelmTemplates/HelmTemplatesListItem.tsx | 14 +-
.../HelmTemplatesSelectedItem.test.tsx | 148 +++++++++++++
.../HelmTemplatesSelectedItem.tsx | 188 ++++++++++++++++
.../queries/useHelmChartInstall.ts | 40 ++++
.../HelmTemplates/queries/useHelmChartList.ts | 79 +++++++
.../queries/useHelmChartValues.ts | 32 +++
app/react/kubernetes/helm/types.ts | 37 ++++
vitest.config.mts | 3 +
20 files changed, 635 insertions(+), 372 deletions(-)
delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates.controller.js
delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates.html
delete mode 100644 app/kubernetes/components/helm/helm-templates/helm-templates.js
create mode 100644 app/react/hooks/useCanExit.ts
rename app/{kubernetes/components/helm/helm-templates => react/kubernetes/helm/HelmTemplates}/HelmIcon.tsx (100%)
create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx
create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
create mode 100644 app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartInstall.ts
create mode 100644 app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts
create mode 100644 app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartValues.ts
create mode 100644 app/react/kubernetes/helm/types.ts
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js b/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js
deleted file mode 100644
index 8b62c6af0..000000000
--- a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js
+++ /dev/null
@@ -1,207 +0,0 @@
-import _ from 'lodash-es';
-import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
-import { confirmWebEditorDiscard } from '@@/modals/confirm';
-import { HelmIcon } from './HelmIcon';
-export default class HelmTemplatesController {
- /* @ngInject */
- constructor($analytics, $async, $state, $window, $anchorScroll, Authentication, HelmService, KubernetesResourcePoolService, Notifications) {
- this.$analytics = $analytics;
- this.$async = $async;
- this.$window = $window;
- this.$state = $state;
- this.$anchorScroll = $anchorScroll;
- this.Authentication = Authentication;
- this.HelmService = HelmService;
- this.KubernetesResourcePoolService = KubernetesResourcePoolService;
- this.Notifications = Notifications;
-
- this.fallbackIcon = HelmIcon;
-
- this.editorUpdate = this.editorUpdate.bind(this);
- this.uiCanExit = this.uiCanExit.bind(this);
- this.installHelmchart = this.installHelmchart.bind(this);
- this.getHelmValues = this.getHelmValues.bind(this);
- this.selectHelmChart = this.selectHelmChart.bind(this);
- this.getHelmRepoURLs = this.getHelmRepoURLs.bind(this);
- this.getLatestCharts = this.getLatestCharts.bind(this);
- this.getResourcePools = this.getResourcePools.bind(this);
- this.clearHelmChart = this.clearHelmChart.bind(this);
-
- $window.onbeforeunload = () => {
- if (this.state.isEditorDirty) {
- return '';
- }
- };
- }
-
- clearHelmChart() {
- this.state.chart = null;
- this.onSelectHelmChart('');
- }
-
- editorUpdate(contentvalues) {
- if (this.state.originalvalues === contentvalues) {
- this.state.isEditorDirty = false;
- } else {
- this.state.values = contentvalues;
- this.state.isEditorDirty = true;
- }
- }
-
- async uiCanExit() {
- if (this.state.isEditorDirty) {
- return confirmWebEditorDiscard();
- }
- }
-
- async installHelmchart() {
- this.state.actionInProgress = true;
- try {
- const payload = {
- Name: this.name,
- Repo: this.state.chart.repo,
- Chart: this.state.chart.name,
- Values: this.state.values,
- Namespace: this.namespace,
- };
- await this.HelmService.install(this.endpoint.Id, payload);
- this.Notifications.success('Success', 'Helm chart successfully installed');
- this.$analytics.eventTrack('kubernetes-helm-install', { category: 'kubernetes', metadata: { 'chart-name': this.state.chart.name } });
- this.state.isEditorDirty = false;
- this.$state.go('kubernetes.applications');
- } catch (err) {
- this.Notifications.error('Installation error', err);
- } finally {
- this.state.actionInProgress = false;
- }
- }
-
- async getHelmValues() {
- this.state.loadingValues = true;
- try {
- const { values } = await this.HelmService.values(this.state.chart.repo, this.state.chart.name);
- this.state.values = values;
- this.state.originalvalues = values;
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve helm chart values.');
- } finally {
- this.state.loadingValues = false;
- }
- }
-
- async selectHelmChart(chart) {
- window.scrollTo(0, 0);
- this.state.showCustomValues = false;
- this.state.chart = chart;
- this.onSelectHelmChart(chart.name);
- await this.getHelmValues();
- }
-
- /**
- * @description This function is used to get the helm repo urls for the endpoint and user
- * @returns {Promise} list of helm repo urls
- */
- async getHelmRepoURLs() {
- this.state.reposLoading = true;
- try {
- // fetch globally set helm repo and user helm repos (parallel)
- const { GlobalRepository, UserRepositories } = await this.HelmService.getHelmRepositories(this.user.ID);
- this.state.globalRepository = GlobalRepository;
- const userHelmReposUrls = UserRepositories.map((repo) => repo.URL);
- const uniqueHelmRepos = [...new Set([GlobalRepository, ...userHelmReposUrls])].map((url) => url.toLowerCase()).filter((url) => url); // remove duplicates and blank, to lowercase
- this.state.repos = uniqueHelmRepos;
- return uniqueHelmRepos;
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve helm repo urls.');
- } finally {
- this.state.reposLoading = false;
- }
- }
-
- /**
- * @description This function is used to fetch the respective index.yaml files for the provided helm repo urls
- * @param {string[]} helmRepos list of helm repositories
- * @param {bool} append append charts returned from repo to existing list of helm charts
- */
- async getLatestCharts(helmRepos) {
- this.state.chartsLoading = true;
- try {
- const promiseList = helmRepos.map((repo) => this.HelmService.search(repo));
- // fetch helm charts from all the provided helm repositories (parallel)
- // Promise.allSettled is used to account for promise failure(s) - in cases the user has provided invalid helm repo
- const chartPromises = await Promise.allSettled(promiseList);
- const latestCharts = chartPromises
- .filter((tp) => tp.status === 'fulfilled') // remove failed promises
- .map((tp) => ({ entries: tp.value.entries, repo: helmRepos[chartPromises.indexOf(tp)] })) // extract chart entries with respective repo data
- .flatMap(
- ({ entries, repo }) => Object.values(entries).map((charts) => ({ ...charts[0], repo })) // flatten chart entries to single array with respective repo
- );
-
- this.state.charts = latestCharts;
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve helm repo charts.');
- } finally {
- this.state.chartsLoading = false;
- }
- }
-
- async getResourcePools() {
- this.state.resourcePoolsLoading = true;
- try {
- const resourcePools = await this.KubernetesResourcePoolService.get();
-
- const nonSystemNamespaces = resourcePools.filter(
- (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active'
- );
- this.state.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1));
- this.state.resourcePool = this.state.resourcePools[0];
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve initial helm data.');
- } finally {
- this.state.resourcePoolsLoading = false;
- }
- }
-
- $onInit() {
- return this.$async(async () => {
- this.user = this.Authentication.getUserDetails();
-
- this.state = {
- appName: '',
- chart: null,
- showCustomValues: false,
- actionInProgress: false,
- resourcePools: [],
- resourcePool: '',
- values: null,
- originalvalues: null,
- repos: [],
- charts: [],
- loadingValues: false,
- isEditorDirty: false,
- chartsLoading: false,
- resourcePoolsLoading: false,
- viewReady: false,
- isAdmin: this.Authentication.isAdmin(),
- globalRepository: undefined,
- };
-
- const helmRepos = await this.getHelmRepoURLs();
- if (helmRepos) {
- await Promise.all([this.getLatestCharts(helmRepos), this.getResourcePools()]);
- }
- if (this.state.charts.length > 0 && this.$state.params.chartName) {
- const chart = this.state.charts.find((chart) => chart.name === this.$state.params.chartName);
- if (chart) {
- this.selectHelmChart(chart);
- }
- }
-
- this.state.viewReady = true;
- });
- }
-
- $onDestroy() {
- this.state.isEditorDirty = false;
- }
-}
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.html b/app/kubernetes/components/helm/helm-templates/helm-templates.html
deleted file mode 100644
index 4b998cf76..000000000
--- a/app/kubernetes/components/helm/helm-templates/helm-templates.html
+++ /dev/null
@@ -1,113 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- {{ $ctrl.state.chart.name }}
-
-
- Helm
-
-
-
-
-
-
-
-
-
- Clear selection
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.js b/app/kubernetes/components/helm/helm-templates/helm-templates.js
deleted file mode 100644
index 9cd4b1158..000000000
--- a/app/kubernetes/components/helm/helm-templates/helm-templates.js
+++ /dev/null
@@ -1,14 +0,0 @@
-import angular from 'angular';
-import controller from './helm-templates.controller';
-
-angular.module('portainer.kubernetes').component('helmTemplatesView', {
- templateUrl: './helm-templates.html',
- controller,
- bindings: {
- endpoint: '<',
- namespace: '<',
- stackName: '<',
- onSelectHelmChart: '<',
- name: '<',
- },
-});
diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts
index 041fc35d2..27aa04444 100644
--- a/app/kubernetes/react/components/index.ts
+++ b/app/kubernetes/react/components/index.ts
@@ -58,8 +58,7 @@ import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/co
import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/EnvironmentVariablesFormSection';
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema';
import { IntegratedAppsDatatable } from '@/react/kubernetes/components/IntegratedAppsDatatable/IntegratedAppsDatatable';
-import { HelmTemplatesList } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplatesList';
-import { HelmTemplatesListItem } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem';
+import { HelmTemplates } from '@/react/kubernetes/helm/HelmTemplates/HelmTemplates';
import { namespacesModule } from './namespaces';
import { clusterManagementModule } from './clusterManagement';
@@ -209,17 +208,12 @@ export const ngModule = angular
])
)
.component(
- 'helmTemplatesList',
- r2a(withUIRouter(withCurrentUser(HelmTemplatesList)), [
- 'loading',
- 'titleText',
- 'charts',
- 'selectAction',
+ 'helmTemplatesView',
+ r2a(withUIRouter(withCurrentUser(HelmTemplates)), [
+ 'onSelectHelmChart',
+ 'namespace',
+ 'name',
])
- )
- .component(
- 'helmTemplatesListItem',
- r2a(HelmTemplatesListItem, ['model', 'onSelect', 'actions'])
);
export const componentsModule = ngModule.name;
diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html
index 35701b76c..5eda2d3a7 100644
--- a/app/kubernetes/views/deploy/deploy.html
+++ b/app/kubernetes/views/deploy/deploy.html
@@ -187,13 +187,7 @@
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js
index 79549084a..89f416ac3 100644
--- a/app/kubernetes/views/deploy/deployController.js
+++ b/app/kubernetes/views/deploy/deployController.js
@@ -16,7 +16,8 @@ import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
class KubernetesDeployController {
/* @ngInject */
- constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) {
+ constructor($scope, $async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) {
+ this.$scope = $scope;
this.$async = $async;
this.$state = $state;
this.$window = $window;
@@ -110,6 +111,9 @@ class KubernetesDeployController {
onSelectHelmChart(chart) {
this.state.selectedHelmChart = chart;
+
+ // Force a digest cycle to ensure the change is reflected in the UI
+ this.$scope.$apply();
}
onChangeTemplateVariables(value) {
diff --git a/app/react/components/modals/confirm.ts b/app/react/components/modals/confirm.ts
index 4ca4a7e91..c7f3ba677 100644
--- a/app/react/components/modals/confirm.ts
+++ b/app/react/components/modals/confirm.ts
@@ -47,6 +47,16 @@ export function confirmWebEditorDiscard() {
});
}
+export function confirmGenericDiscard() {
+ return openConfirm({
+ modalType: ModalType.Warn,
+ title: 'Are you sure?',
+ message:
+ 'You currently have unsaved changes. Are you sure you want to leave?',
+ confirmButton: buildConfirmButton('Yes', 'danger'),
+ });
+}
+
export function confirmDelete(message: ReactNode) {
return confirmDestructive({
title: 'Are you sure?',
diff --git a/app/react/hooks/useCanExit.ts b/app/react/hooks/useCanExit.ts
new file mode 100644
index 000000000..b7366be23
--- /dev/null
+++ b/app/react/hooks/useCanExit.ts
@@ -0,0 +1,17 @@
+/**
+ * Copied from https://github.com/ui-router/react/blob/master/src/hooks/useCanExit.ts
+ * TODO: Use package version of this hook when it becomes available: https://github.com/ui-router/react/pull/1227
+ */
+import { useParentView, useTransitionHook } from '@uirouter/react';
+
+/**
+ * A hook that can stop the router from exiting the state the hook is used in.
+ * If the callback returns true/undefined (or a Promise that resolves to true/undefined), the Transition will be allowed to continue.
+ * If the callback returns false (or a Promise that resolves to false), the Transition will be cancelled.
+ */
+export function useCanExit(
+ canExitCallback: () => boolean | undefined | Promise
+) {
+ const stateName = useParentView().context.name;
+ useTransitionHook('onBefore', { exiting: stateName }, canExitCallback);
+}
diff --git a/app/kubernetes/components/helm/helm-templates/HelmIcon.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmIcon.tsx
similarity index 100%
rename from app/kubernetes/components/helm/helm-templates/HelmIcon.tsx
rename to app/react/kubernetes/helm/HelmTemplates/HelmIcon.tsx
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx
new file mode 100644
index 000000000..c8def2077
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx
@@ -0,0 +1,55 @@
+import { useState } from 'react';
+
+import { useCurrentUser } from '@/react/hooks/useUser';
+
+import { Chart } from '../types';
+
+import { useHelmChartList } from './queries/useHelmChartList';
+import { HelmTemplatesList } from './HelmTemplatesList';
+import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
+
+interface Props {
+ onSelectHelmChart: (chartName: string) => void;
+ namespace?: string;
+ name?: string;
+}
+
+export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
+ const [selectedChart, setSelectedChart] = useState(null);
+
+ const { user } = useCurrentUser();
+ const { data: charts = [], isLoading: chartsLoading } = useHelmChartList(
+ user.Id
+ );
+
+ function clearHelmChart() {
+ setSelectedChart(null);
+ onSelectHelmChart('');
+ }
+
+ function handleChartSelection(chart: Chart) {
+ setSelectedChart(chart);
+ onSelectHelmChart(chart.name);
+ }
+
+ return (
+
+
+ {selectedChart ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx
index e08e50cad..3c080d077 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx
@@ -6,14 +6,16 @@ import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
+import { Chart } from '../types';
+
import { HelmTemplatesList } from './HelmTemplatesList';
-import { Chart } from './HelmTemplatesListItem';
// Sample test data
const mockCharts: Chart[] = [
{
name: 'test-chart-1',
description: 'Test Chart 1 Description',
+ repo: 'https://example.com',
annotations: {
category: 'database',
},
@@ -21,6 +23,7 @@ const mockCharts: Chart[] = [
{
name: 'test-chart-2',
description: 'Test Chart 2 Description',
+ repo: 'https://example.com',
annotations: {
category: 'database',
},
@@ -28,6 +31,7 @@ const mockCharts: Chart[] = [
{
name: 'nginx-chart',
description: 'Nginx Web Server',
+ repo: 'https://example.com',
annotations: {
category: 'web',
},
@@ -38,7 +42,6 @@ const selectActionMock = vi.fn();
function renderComponent({
loading = false,
- titleText = 'Test Helm Templates',
charts = mockCharts,
selectAction = selectActionMock,
} = {}) {
@@ -48,7 +51,6 @@ function renderComponent({
withTestRouter(() => (
@@ -68,7 +70,7 @@ describe('HelmTemplatesList', () => {
renderComponent();
// Check for the title
- expect(screen.getByText('Test Helm Templates')).toBeInTheDocument();
+ expect(screen.getByText('Helm chart')).toBeInTheDocument();
// Check for charts
expect(screen.getByText('test-chart-1')).toBeInTheDocument();
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
index 5cfdb6953..3d9034f4c 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
@@ -6,11 +6,12 @@ import { Link } from '@/react/components/Link';
import { InsightsBox } from '@@/InsightsBox';
import { SearchBar } from '@@/datatables/SearchBar';
-import { Chart, HelmTemplatesListItem } from './HelmTemplatesListItem';
+import { Chart } from '../types';
+
+import { HelmTemplatesListItem } from './HelmTemplatesListItem';
interface Props {
loading: boolean;
- titleText: string;
charts?: Chart[];
selectAction: (chart: Chart) => void;
}
@@ -70,7 +71,6 @@ function getFilteredCharts(
export function HelmTemplatesList({
loading,
- titleText,
charts = [],
selectAction,
}: Props) {
@@ -87,7 +87,7 @@ export function HelmTemplatesList({
return (
-
{titleText}
+
Helm chart
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
new file mode 100644
index 000000000..655b1d928
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
@@ -0,0 +1,148 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MutationOptions } from '@tanstack/react-query';
+import { vi } from 'vitest';
+
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+import { withUserProvider } from '@/react/test-utils/withUserProvider';
+import { withTestRouter } from '@/react/test-utils/withRouter';
+import { UserViewModel } from '@/portainer/models/user';
+
+import { Chart } from '../types';
+
+import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
+
+const mockMutate = vi.fn();
+const mockNotifySuccess = vi.fn();
+
+// Mock dependencies
+vi.mock('@/portainer/services/notifications', () => ({
+ notifySuccess: (title: string, text: string) =>
+ mockNotifySuccess(title, text),
+}));
+
+vi.mock('./queries/useHelmChartValues', () => ({
+ useHelmChartValues: vi.fn().mockReturnValue({
+ data: { values: 'test-values' },
+ isLoading: false,
+ }),
+}));
+
+vi.mock('./queries/useHelmChartInstall', () => ({
+ useHelmChartInstall: vi.fn().mockReturnValue({
+ mutate: (params: Record, options?: MutationOptions) =>
+ mockMutate(params, options),
+ isLoading: false,
+ }),
+}));
+
+vi.mock('@/react/hooks/useAnalytics', () => ({
+ useAnalytics: vi.fn().mockReturnValue({
+ trackEvent: vi.fn(),
+ }),
+}));
+
+// Sample test data
+const mockChart: Chart = {
+ name: 'test-chart',
+ description: 'Test Chart Description',
+ repo: 'https://example.com',
+ icon: 'test-icon-url',
+ annotations: {
+ category: 'database',
+ },
+};
+
+const clearHelmChartMock = vi.fn();
+const mockRouterStateService = {
+ go: vi.fn(),
+};
+
+function renderComponent({
+ selectedChart = mockChart,
+ clearHelmChart = clearHelmChartMock,
+ namespace = 'test-namespace',
+ name = 'test-name',
+} = {}) {
+ const user = new UserViewModel({ Username: 'user' });
+
+ const Wrapped = withTestQueryProvider(
+ withUserProvider(
+ withTestRouter(() => (
+
+ )),
+ user
+ )
+ );
+
+ return {
+ ...render( ),
+ user,
+ mockRouterStateService,
+ };
+}
+
+describe('HelmTemplatesSelectedItem', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should display selected chart information', () => {
+ renderComponent();
+
+ // Check for chart details
+ expect(screen.getByText('test-chart')).toBeInTheDocument();
+ expect(screen.getByText('Test Chart Description')).toBeInTheDocument();
+ expect(screen.getByText('Clear selection')).toBeInTheDocument();
+ expect(screen.getByText('Helm')).toBeInTheDocument();
+ });
+
+ it('should toggle custom values editor', async () => {
+ renderComponent();
+ const user = userEvent.setup();
+
+ // First show the editor
+ await user.click(await screen.findByText('Custom values'));
+
+ // Verify editor is visible
+ expect(screen.getByTestId('helm-app-creation-editor')).toBeInTheDocument();
+
+ // Now hide the editor
+ await user.click(await screen.findByText('Custom values'));
+
+ // Editor should be hidden
+ expect(
+ screen.queryByTestId('helm-app-creation-editor')
+ ).not.toBeInTheDocument();
+ });
+
+ it('should install helm chart and navigate when install button is clicked', async () => {
+ const user = userEvent.setup();
+ renderComponent();
+
+ // Click install button
+ await user.click(screen.getByText('Install'));
+
+ // Check mutate was called with correct values
+ expect(mockMutate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ Name: 'test-name',
+ Repo: 'https://example.com',
+ Chart: 'test-chart',
+ Values: 'test-values',
+ Namespace: 'test-namespace',
+ }),
+ expect.objectContaining({ onSuccess: expect.any(Function) })
+ );
+ });
+
+ it('should disable install button when namespace or name is undefined', () => {
+ renderComponent({ namespace: '' });
+ expect(screen.getByText('Install')).toBeDisabled();
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
new file mode 100644
index 000000000..088c0c69a
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
@@ -0,0 +1,188 @@
+import { useRef } from 'react';
+import { X } from 'lucide-react';
+import { Form, Formik, FormikProps } from 'formik';
+import { useRouter } from '@uirouter/react';
+
+import { notifySuccess } from '@/portainer/services/notifications';
+import { useAnalytics } from '@/react/hooks/useAnalytics';
+import { useCanExit } from '@/react/hooks/useCanExit';
+
+import { Widget } from '@@/Widget';
+import { Button } from '@@/buttons/Button';
+import { FallbackImage } from '@@/FallbackImage';
+import Svg from '@@/Svg';
+import { Icon } from '@@/Icon';
+import { WebEditorForm } from '@@/WebEditorForm';
+import { confirmGenericDiscard } from '@@/modals/confirm';
+import { FormSection } from '@@/form-components/FormSection';
+import { InlineLoader } from '@@/InlineLoader';
+import { FormActions } from '@@/form-components/FormActions';
+
+import { Chart } from '../types';
+
+import { useHelmChartValues } from './queries/useHelmChartValues';
+import { HelmIcon } from './HelmIcon';
+import { useHelmChartInstall } from './queries/useHelmChartInstall';
+
+type Props = {
+ selectedChart: Chart;
+ clearHelmChart: () => void;
+ namespace?: string;
+ name?: string;
+};
+
+type FormValues = {
+ values: string;
+};
+
+const emptyValues: FormValues = {
+ values: '',
+};
+
+export function HelmTemplatesSelectedItem({
+ selectedChart,
+ clearHelmChart,
+ namespace,
+ name,
+}: Props) {
+ const router = useRouter();
+ const analytics = useAnalytics();
+
+ const { mutate: installHelmChart, isLoading: isInstalling } =
+ useHelmChartInstall();
+ const { data: initialValues, isLoading: loadingValues } =
+ useHelmChartValues(selectedChart);
+
+ const formikRef = useRef>(null);
+ useCanExit(() => !formikRef.current?.dirty || confirmGenericDiscard());
+
+ function handleSubmit(values: FormValues) {
+ if (!name || !namespace) {
+ // Theoretically this should never happen and is mainly to keep typescript happy
+ return;
+ }
+
+ installHelmChart(
+ {
+ Name: name,
+ Repo: selectedChart.repo,
+ Chart: selectedChart.name,
+ Values: values.values,
+ Namespace: namespace,
+ },
+ {
+ onSuccess() {
+ analytics.trackEvent('kubernetes-helm-install', {
+ category: 'kubernetes',
+ metadata: {
+ 'chart-name': selectedChart.name,
+ },
+ });
+ notifySuccess('Success', 'Helm chart successfully installed');
+
+ // Reset the form so page can be navigated away from without getting "Are you sure?"
+ formikRef.current?.resetForm();
+ router.stateService.go('kubernetes.applications');
+ },
+ }
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ {selectedChart.name}
+
+
+
+
+ {' '}
+ Helm
+
+
+
+
+ {selectedChart.description}
+
+
+
+
+
+
+
+ Clear selection
+
+
+
+
+
+
+ handleSubmit(values)}
+ >
+ {({ values, setFieldValue }) => (
+
+ )}
+
+ >
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartInstall.ts b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartInstall.ts
new file mode 100644
index 000000000..e6664a317
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartInstall.ts
@@ -0,0 +1,40 @@
+import { useMutation } from '@tanstack/react-query';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import {
+ queryClient,
+ withGlobalError,
+ withInvalidate,
+} from '@/react-tools/react-query';
+import { queryKeys } from '@/react/kubernetes/applications/queries/query-keys';
+
+import { InstallChartPayload } from '../../types';
+
+async function installHelmChart(
+ payload: InstallChartPayload,
+ environmentId: EnvironmentId
+) {
+ try {
+ const response = await axios.post(
+ `endpoints/${environmentId}/kubernetes/helm`,
+ payload
+ );
+ return response.data;
+ } catch (err) {
+ throw parseAxiosError(err as Error, 'Installation error');
+ }
+}
+
+export function useHelmChartInstall() {
+ const environmentId = useEnvironmentId();
+
+ return useMutation(
+ (values: InstallChartPayload) => installHelmChart(values, environmentId),
+ {
+ ...withGlobalError('Unable to install Helm chart'),
+ ...withInvalidate(queryClient, [queryKeys.applications(environmentId)]),
+ }
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts
new file mode 100644
index 000000000..60791fe59
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts
@@ -0,0 +1,79 @@
+import { useQuery } from '@tanstack/react-query';
+import { compact } from 'lodash';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { withGlobalError } from '@/react-tools/react-query';
+
+import {
+ Chart,
+ HelmChartsResponse,
+ HelmRepositoriesResponse,
+} from '../../types';
+
+async function getHelmRepositories(userId: number): Promise {
+ try {
+ const response = await axios.get(
+ `users/${userId}/helm/repositories`
+ );
+ const { GlobalRepository, UserRepositories } = response.data;
+
+ // Extract URLs from user repositories
+ const userHelmReposUrls = UserRepositories.map((repo) => repo.URL);
+
+ // Combine global and user repositories, remove duplicates and empty values
+ const uniqueHelmRepos = [
+ ...new Set([GlobalRepository, ...userHelmReposUrls]),
+ ]
+ .map((url) => url.toLowerCase())
+ .filter((url) => url);
+
+ return uniqueHelmRepos;
+ } catch (err) {
+ throw parseAxiosError(err, 'Failed to fetch Helm repositories');
+ }
+}
+
+async function getChartsFromRepo(repo: string): Promise {
+ try {
+ // Construct the URL with required repo parameter
+ const response = await axios.get('templates/helm', {
+ params: { repo },
+ });
+
+ return compact(
+ Object.values(response.data.entries).map((versions) =>
+ versions[0] ? { ...versions[0], repo } : null
+ )
+ );
+ } catch (error) {
+ // Ignore errors from chart repositories as some may error but others may not
+ return [];
+ }
+}
+
+async function getCharts(userId: number): Promise {
+ try {
+ // First, get all the helm repositories
+ const repos = await getHelmRepositories(userId);
+
+ // Then fetch charts from each repository in parallel
+ const chartsPromises = repos.map((repo) => getChartsFromRepo(repo));
+ const chartsArrays = await Promise.all(chartsPromises);
+
+ // Flatten the arrays of charts into a single array
+ return chartsArrays.flat();
+ } catch (err) {
+ throw parseAxiosError(err, 'Failed to fetch Helm charts');
+ }
+}
+
+/**
+ * React hook to fetch helm charts from all accessible repositories
+ * @param userId User ID
+ */
+export function useHelmChartList(userId: number) {
+ return useQuery([userId, 'helm-charts'], () => getCharts(userId), {
+ enabled: !!userId,
+ ...withGlobalError('Unable to retrieve Helm charts'),
+ });
+}
diff --git a/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartValues.ts b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartValues.ts
new file mode 100644
index 000000000..29ec6e45a
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartValues.ts
@@ -0,0 +1,32 @@
+import { useQuery } from '@tanstack/react-query';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { withGlobalError } from '@/react-tools/react-query';
+
+import { Chart } from '../../types';
+
+async function getHelmChartValues(chart: string, repo: string) {
+ try {
+ const response = await axios.get(`/templates/helm/values`, {
+ params: {
+ repo,
+ chart,
+ },
+ });
+ return response.data;
+ } catch (err) {
+ throw parseAxiosError(err as Error, 'Unable to get Helm chart values');
+ }
+}
+
+export function useHelmChartValues(chart: Chart) {
+ return useQuery({
+ queryKey: ['helm-chart-values', chart.repo, chart.name],
+ queryFn: () => getHelmChartValues(chart.name, chart.repo),
+ enabled: !!chart.name,
+ select: (data) => ({
+ values: data,
+ }),
+ ...withGlobalError('Unable to get Helm chart values'),
+ });
+}
diff --git a/app/react/kubernetes/helm/types.ts b/app/react/kubernetes/helm/types.ts
new file mode 100644
index 000000000..8b043a080
--- /dev/null
+++ b/app/react/kubernetes/helm/types.ts
@@ -0,0 +1,37 @@
+export interface Chart extends HelmChartResponse {
+ repo: string;
+}
+
+export interface HelmChartResponse {
+ name: string;
+ description: string;
+ icon?: string;
+ annotations?: {
+ category?: string;
+ };
+}
+
+export interface HelmRepositoryResponse {
+ Id: number;
+ UserId: number;
+ URL: string;
+}
+
+export interface HelmRepositoriesResponse {
+ GlobalRepository: string;
+ UserRepositories: HelmRepositoryResponse[];
+}
+
+export interface HelmChartsResponse {
+ entries: Record;
+ apiVersion: string;
+ generated: string;
+}
+
+export type InstallChartPayload = {
+ Name: string;
+ Repo: string;
+ Chart: string;
+ Values: string;
+ Namespace: string;
+};
diff --git a/vitest.config.mts b/vitest.config.mts
index eca3b62ed..7c343f6b7 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -24,6 +24,9 @@ export default defineConfig({
env: {
PORTAINER_EDITION: 'CE',
},
+ deps: {
+ inline: [/@radix-ui/, /codemirror-json-schema/], // https://github.com/radix-ui/primitives/issues/2974#issuecomment-2186808459
+ },
},
plugins: [svgr({ include: /\?c$/ }), tsconfigPaths()],
});
From 64c796a8c36fc8fddafd9752c1028b1b7223018b Mon Sep 17 00:00:00 2001
From: James Player
Date: Tue, 8 Apr 2025 12:52:21 +1200
Subject: [PATCH 081/212] fix(kubernetes): Config maps and secrets show as
unused BE-11684 (#596)
Co-authored-by: stevensbkang
---
api/http/handler/kubernetes/configmaps.go | 4 +-
api/http/handler/kubernetes/secrets.go | 4 +-
api/kubernetes/cli/applications.go | 100 +++++++---------------
api/kubernetes/cli/configmap.go | 48 +++++------
api/kubernetes/cli/pod.go | 21 +++--
api/kubernetes/cli/secret.go | 47 +++++-----
6 files changed, 91 insertions(+), 133 deletions(-)
diff --git a/api/http/handler/kubernetes/configmaps.go b/api/http/handler/kubernetes/configmaps.go
index 633afe92d..bd10ae15d 100644
--- a/api/http/handler/kubernetes/configmaps.go
+++ b/api/http/handler/kubernetes/configmaps.go
@@ -146,13 +146,11 @@ func (handler *Handler) getAllKubernetesConfigMaps(r *http.Request) ([]models.K8
}
if isUsed {
- configMapsWithApplications, err := cli.CombineConfigMapsWithApplications(configMaps)
+ err = cli.SetConfigMapsIsUsed(&configMaps)
if err != nil {
log.Error().Err(err).Str("context", "getAllKubernetesConfigMaps").Msg("Unable to combine configMaps with associated applications")
return nil, httperror.InternalServerError("Unable to combine configMaps with associated applications", err)
}
-
- return configMapsWithApplications, nil
}
return configMaps, nil
diff --git a/api/http/handler/kubernetes/secrets.go b/api/http/handler/kubernetes/secrets.go
index 1375e9e6b..782e6107e 100644
--- a/api/http/handler/kubernetes/secrets.go
+++ b/api/http/handler/kubernetes/secrets.go
@@ -130,13 +130,11 @@ func (handler *Handler) getAllKubernetesSecrets(r *http.Request) ([]models.K8sSe
}
if isUsed {
- secretsWithApplications, err := cli.CombineSecretsWithApplications(secrets)
+ err = cli.SetSecretsIsUsed(&secrets)
if err != nil {
log.Error().Err(err).Str("context", "GetAllKubernetesSecrets").Msg("Unable to combine secrets with associated applications")
return nil, httperror.InternalServerError("unable to combine secrets with associated applications. Error: ", err)
}
-
- return secretsWithApplications, nil
}
return secrets, nil
diff --git a/api/kubernetes/cli/applications.go b/api/kubernetes/cli/applications.go
index c08329a7b..8dd4d72b2 100644
--- a/api/kubernetes/cli/applications.go
+++ b/api/kubernetes/cli/applications.go
@@ -153,46 +153,6 @@ func (kcl *KubeClient) GetApplicationsResource(namespace, node string) (models.K
return resource, nil
}
-// GetApplicationsFromConfigMap gets a list of applications that use a specific ConfigMap
-// by checking all pods in the same namespace as the ConfigMap
-func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConfigMap, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]string, error) {
- applications := []string{}
- for _, pod := range pods {
- if pod.Namespace == configMap.Namespace {
- if isPodUsingConfigMap(&pod, configMap.Name) {
- application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
- ReplicaSets: replicaSets,
- }, false)
- if err != nil {
- return nil, err
- }
- applications = append(applications, application.Name)
- }
- }
- }
-
- return applications, nil
-}
-
-func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]string, error) {
- applications := []string{}
- for _, pod := range pods {
- if pod.Namespace == secret.Namespace {
- if isPodUsingSecret(&pod, secret.Name) {
- application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
- ReplicaSets: replicaSets,
- }, false)
- if err != nil {
- return nil, err
- }
- applications = append(applications, application.Name)
- }
- }
- }
-
- return applications, nil
-}
-
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, portainerApplicationResources PortainerApplicationResources, withResource bool) (*models.K8sApplication, error) {
if isReplicaSetOwner(pod) {
@@ -473,23 +433,23 @@ func (kcl *KubeClient) GetApplicationFromServiceSelector(pods []corev1.Pod, serv
func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap models.K8sConfigMap, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]models.K8sConfigurationOwnerResource, error) {
configurationOwners := []models.K8sConfigurationOwnerResource{}
for _, pod := range pods {
- if pod.Namespace == configMap.Namespace {
- if isPodUsingConfigMap(&pod, configMap.Name) {
- application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
- ReplicaSets: replicaSets,
- }, false)
- if err != nil {
- return nil, err
- }
+ if isPodUsingConfigMap(&pod, configMap) {
+ kind := "Pod"
+ name := pod.Name
- if application != nil {
- configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
- Name: application.Name,
- ResourceKind: application.Kind,
- Id: application.UID,
- })
- }
+ if len(pod.OwnerReferences) > 0 {
+ kind = pod.OwnerReferences[0].Kind
+ name = pod.OwnerReferences[0].Name
}
+
+ if isReplicaSetOwner(pod) {
+ updateOwnerReferenceToDeployment(&pod, replicaSets)
+ }
+
+ configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
+ Name: name,
+ ResourceKind: kind,
+ })
}
}
@@ -501,23 +461,23 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models.K8sSecret, pods []corev1.Pod, replicaSets []appsv1.ReplicaSet) ([]models.K8sConfigurationOwnerResource, error) {
configurationOwners := []models.K8sConfigurationOwnerResource{}
for _, pod := range pods {
- if pod.Namespace == secret.Namespace {
- if isPodUsingSecret(&pod, secret.Name) {
- application, err := kcl.ConvertPodToApplication(pod, PortainerApplicationResources{
- ReplicaSets: replicaSets,
- }, false)
- if err != nil {
- return nil, err
- }
+ if isPodUsingSecret(&pod, secret) {
+ kind := "Pod"
+ name := pod.Name
- if application != nil {
- configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
- Name: application.Name,
- ResourceKind: application.Kind,
- Id: application.UID,
- })
- }
+ if len(pod.OwnerReferences) > 0 {
+ kind = pod.OwnerReferences[0].Kind
+ name = pod.OwnerReferences[0].Name
}
+
+ if isReplicaSetOwner(pod) {
+ updateOwnerReferenceToDeployment(&pod, replicaSets)
+ }
+
+ configurationOwners = append(configurationOwners, models.K8sConfigurationOwnerResource{
+ Name: name,
+ ResourceKind: kind,
+ })
}
}
diff --git a/api/kubernetes/cli/configmap.go b/api/kubernetes/cli/configmap.go
index eb0eec935..fafa81346 100644
--- a/api/kubernetes/cli/configmap.go
+++ b/api/kubernetes/cli/configmap.go
@@ -7,6 +7,7 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
+ appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@@ -95,35 +96,28 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
return result
}
-// CombineConfigMapsWithApplications combines the config maps with the applications that use them.
+// SetConfigMapsIsUsed combines the config maps with the applications that use them.
// the function fetches all the pods and replica sets in the cluster and checks if the config map is used by any of the pods.
// if the config map is used by a pod, the application that uses the pod is added to the config map.
// otherwise, the config map is returned as is.
-func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
- updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
-
+func (kcl *KubeClient) SetConfigMapsIsUsed(configMaps *[]models.K8sConfigMap) error {
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
if err != nil {
- return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
+ return fmt.Errorf("an error occurred during the SetConfigMapsIsUsed operation, unable to fetch Portainer application resources. Error: %w", err)
}
- for index, configMap := range configMaps {
- updatedConfigMap := configMap
+ for i := range *configMaps {
+ configMap := &(*configMaps)[i]
- applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
- if err != nil {
- return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to get applications from config map. Error: %w", err)
+ for _, pod := range portainerApplicationResources.Pods {
+ if isPodUsingConfigMap(&pod, *configMap) {
+ configMap.IsUsed = true
+ break
+ }
}
-
- if len(applicationConfigurationOwners) > 0 {
- updatedConfigMap.ConfigurationOwnerResources = applicationConfigurationOwners
- updatedConfigMap.IsUsed = true
- }
-
- updatedConfigMaps[index] = updatedConfigMap
}
- return updatedConfigMaps, nil
+ return nil
}
// CombineConfigMapWithApplications combines the config map with the applications that use it.
@@ -141,20 +135,22 @@ func (kcl *KubeClient) CombineConfigMapWithApplications(configMap models.K8sConf
break
}
+ var replicaSets *appsv1.ReplicaSetList
if containsReplicaSetOwner {
- replicaSets, err := kcl.cli.AppsV1().ReplicaSets(configMap.Namespace).List(context.Background(), metav1.ListOptions{})
+ replicaSets, err = kcl.cli.AppsV1().ReplicaSets(configMap.Namespace).List(context.Background(), metav1.ListOptions{})
if err != nil {
return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get replica sets. Error: %w", err)
}
+ }
- applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods.Items, replicaSets.Items)
- if err != nil {
- return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get applications from config map. Error: %w", err)
- }
+ applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromConfigMap(configMap, pods.Items, replicaSets.Items)
+ if err != nil {
+ return models.K8sConfigMap{}, fmt.Errorf("an error occurred during the CombineConfigMapWithApplications operation, unable to get applications from config map. Error: %w", err)
+ }
- if len(applicationConfigurationOwners) > 0 {
- configMap.ConfigurationOwnerResources = applicationConfigurationOwners
- }
+ if len(applicationConfigurationOwners) > 0 {
+ configMap.ConfigurationOwnerResources = applicationConfigurationOwners
+ configMap.IsUsed = true
}
return configMap, nil
diff --git a/api/kubernetes/cli/pod.go b/api/kubernetes/cli/pod.go
index bb3e46077..eb8992124 100644
--- a/api/kubernetes/cli/pod.go
+++ b/api/kubernetes/cli/pod.go
@@ -7,6 +7,7 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
+ models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
@@ -235,16 +236,20 @@ func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podLi
}
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
-func isPodUsingConfigMap(pod *corev1.Pod, configMapName string) bool {
+func isPodUsingConfigMap(pod *corev1.Pod, configMap models.K8sConfigMap) bool {
+ if pod.Namespace != configMap.Namespace {
+ return false
+ }
+
for _, volume := range pod.Spec.Volumes {
- if volume.ConfigMap != nil && volume.ConfigMap.Name == configMapName {
+ if volume.ConfigMap != nil && volume.ConfigMap.Name == configMap.Name {
return true
}
}
for _, container := range pod.Spec.Containers {
for _, env := range container.Env {
- if env.ValueFrom != nil && env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == configMapName {
+ if env.ValueFrom != nil && env.ValueFrom.ConfigMapKeyRef != nil && env.ValueFrom.ConfigMapKeyRef.Name == configMap.Name {
return true
}
}
@@ -254,16 +259,20 @@ func isPodUsingConfigMap(pod *corev1.Pod, configMapName string) bool {
}
// isPodUsingSecret checks if a pod is using a specific Secret
-func isPodUsingSecret(pod *corev1.Pod, secretName string) bool {
+func isPodUsingSecret(pod *corev1.Pod, secret models.K8sSecret) bool {
+ if pod.Namespace != secret.Namespace {
+ return false
+ }
+
for _, volume := range pod.Spec.Volumes {
- if volume.Secret != nil && volume.Secret.SecretName == secretName {
+ if volume.Secret != nil && volume.Secret.SecretName == secret.Name {
return true
}
}
for _, container := range pod.Spec.Containers {
for _, env := range container.Env {
- if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == secretName {
+ if env.ValueFrom != nil && env.ValueFrom.SecretKeyRef != nil && env.ValueFrom.SecretKeyRef.Name == secret.Name {
return true
}
}
diff --git a/api/kubernetes/cli/secret.go b/api/kubernetes/cli/secret.go
index 5b7bf4fef..5bcd386cb 100644
--- a/api/kubernetes/cli/secret.go
+++ b/api/kubernetes/cli/secret.go
@@ -8,6 +8,7 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
+ appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -111,34 +112,28 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
return result
}
-// CombineSecretsWithApplications combines the secrets with the applications that use them.
+// SetSecretsIsUsed combines the secrets with the applications that use them.
// the function fetches all the pods and replica sets in the cluster and checks if the secret is used by any of the pods.
// if the secret is used by a pod, the application that uses the pod is added to the secret.
// otherwise, the secret is returned as is.
-func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
- updatedSecrets := make([]models.K8sSecret, len(secrets))
-
+func (kcl *KubeClient) SetSecretsIsUsed(secrets *[]models.K8sSecret) error {
portainerApplicationResources, err := kcl.fetchAllApplicationsListResources("", metav1.ListOptions{})
if err != nil {
- return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
+ return fmt.Errorf("an error occurred during the SetSecretsIsUsed operation, unable to fetch Portainer application resources. Error: %w", err)
}
- for index, secret := range secrets {
- updatedSecret := secret
+ for i := range *secrets {
+ secret := &(*secrets)[i]
- applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, portainerApplicationResources.Pods, portainerApplicationResources.ReplicaSets)
- if err != nil {
- return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to get applications from secret. Error: %w", err)
+ for _, pod := range portainerApplicationResources.Pods {
+ if isPodUsingSecret(&pod, *secret) {
+ secret.IsUsed = true
+ break
+ }
}
-
- if len(applicationConfigurationOwners) > 0 {
- updatedSecret.ConfigurationOwnerResources = applicationConfigurationOwners
- }
-
- updatedSecrets[index] = updatedSecret
}
- return updatedSecrets, nil
+ return nil
}
// CombineSecretWithApplications combines the secret with the applications that use it.
@@ -156,20 +151,22 @@ func (kcl *KubeClient) CombineSecretWithApplications(secret models.K8sSecret) (m
break
}
+ var replicaSets *appsv1.ReplicaSetList
if containsReplicaSetOwner {
- replicaSets, err := kcl.cli.AppsV1().ReplicaSets(secret.Namespace).List(context.Background(), metav1.ListOptions{})
+ replicaSets, err = kcl.cli.AppsV1().ReplicaSets(secret.Namespace).List(context.Background(), metav1.ListOptions{})
if err != nil {
return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get replica sets. Error: %w", err)
}
+ }
- applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods.Items, replicaSets.Items)
- if err != nil {
- return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get applications from secret. Error: %w", err)
- }
+ applicationConfigurationOwners, err := kcl.GetApplicationConfigurationOwnersFromSecret(secret, pods.Items, replicaSets.Items)
+ if err != nil {
+ return models.K8sSecret{}, fmt.Errorf("an error occurred during the CombineSecretWithApplications operation, unable to get applications from secret. Error: %w", err)
+ }
- if len(applicationConfigurationOwners) > 0 {
- secret.ConfigurationOwnerResources = applicationConfigurationOwners
- }
+ if len(applicationConfigurationOwners) > 0 {
+ secret.ConfigurationOwnerResources = applicationConfigurationOwners
+ secret.IsUsed = true
}
return secret, nil
From 46eddbe7b968e7a3b9657b89c89578d1daf373b3 Mon Sep 17 00:00:00 2001
From: James Player
Date: Wed, 9 Apr 2025 09:09:07 +1200
Subject: [PATCH 082/212] fix(UI): Make sure localStorage.getUserId actually
returns user id R8S-290 (#623)
---
app/portainer/services/localStorage.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js
index d00150d6c..038c75244 100644
--- a/app/portainer/services/localStorage.js
+++ b/app/portainer/services/localStorage.js
@@ -30,7 +30,7 @@ angular.module('portainer.app').factory('LocalStorage', [
return localStorageService.get('UI_STATE');
},
getUserId() {
- localStorageService.get('USER_ID');
+ return localStorageService.get('USER_ID');
},
storeUserId: function (userId) {
localStorageService.set('USER_ID', userId);
From 0ca9321db1b73b52a5197bb856d53c62376a5e92 Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Thu, 10 Apr 2025 16:08:24 +1200
Subject: [PATCH 083/212] feat(helm): update helm view [r8s-256] (#582)
Co-authored-by: Cara Ryan
Co-authored-by: James Player
Co-authored-by: stevensbkang
---
api/http/handler/helm/handler.go | 8 +
api/http/handler/helm/helm_get.go | 67 ++++
api/http/handler/helm/helm_get_test.go | 66 ++++
api/http/handler/helm/helm_history.go | 58 ++++
api/http/handler/helm/helm_history_test.go | 67 ++++
api/http/handler/kubernetes/describe.go | 73 +++++
api/http/handler/kubernetes/handler.go | 35 +++
app/react/components/CodeEditor.tsx | 4 +-
app/react/components/NavTabs/index.ts | 1 +
app/react/components/StatusBadge.tsx | 61 +++-
app/react/components/datatables/Datatable.tsx | 3 +
.../components/datatables/DatatableHeader.tsx | 8 +-
app/react/components/modals/Modal/Modal.tsx | 12 +-
app/react/kubernetes/HelmView/.keep | 0
.../HelmApplicationView.test.tsx | 122 +++++---
.../HelmApplicationView.tsx | 64 +++-
.../HelmApplicationView/HelmDetailsWidget.tsx | 67 ----
.../helm/HelmApplicationView/HelmSummary.tsx | 111 +++++++
.../ReleaseDetails/ManifestDetails.tsx | 18 ++
.../ReleaseDetails/NotesDetails.tsx | 9 +
.../ReleaseDetails/ReleaseTabs.tsx | 68 ++++
.../ResourcesTable/DescribeModal.test.tsx | 123 ++++++++
.../ResourcesTable/DescribeModal.tsx | 57 ++++
.../ResourcesTable/ResourcesTable.test.tsx | 148 +++++++++
.../ResourcesTable/ResourcesTable.tsx | 38 +++
.../ResourcesTable/columns/actions.tsx | 46 +++
.../ResourcesTable/columns/helper.ts | 5 +
.../ResourcesTable/columns/index.ts | 7 +
.../ResourcesTable/columns/name.tsx | 33 ++
.../ResourcesTable/columns/resourceType.tsx | 6 +
.../ResourcesTable/columns/status.tsx | 18 ++
.../ResourcesTable/columns/statusMessage.tsx | 11 +
.../queries/useDescribeResource.ts | 62 ++++
.../ReleaseDetails/ResourcesTable/types.ts | 27 ++
.../ResourcesTable/useResourceRows.ts | 93 ++++++
.../ReleaseDetails/ValuesDetails.tsx | 44 +++
.../queries/useHelmRelease.ts | 81 ++---
app/react/kubernetes/helm/types.ts | 76 +++++
go.mod | 7 +-
go.sum | 4 +
package.json | 1 +
pkg/libhelm/options/get_options.go | 20 +-
pkg/libhelm/options/history_options.go | 9 +
pkg/libhelm/release/release.go | 18 +-
pkg/libhelm/sdk/get.go | 100 ++++++
pkg/libhelm/sdk/get_test.go | 39 +++
pkg/libhelm/sdk/history.go | 68 ++++
pkg/libhelm/sdk/history_test.go | 33 ++
pkg/libhelm/sdk/resources.go | 291 ++++++++++++++++++
pkg/libhelm/sdk/resources_test.go | 143 +++++++++
pkg/libhelm/sdk/values.go | 76 +++++
pkg/libhelm/test/mock.go | 40 +--
pkg/libhelm/types/types.go | 2 +
pkg/libkubectl/client.go | 63 ++++
pkg/libkubectl/client_test.go | 101 ++++++
pkg/libkubectl/describe.go | 40 +++
yarn.lock | 5 +
57 files changed, 2635 insertions(+), 222 deletions(-)
create mode 100644 api/http/handler/helm/helm_get.go
create mode 100644 api/http/handler/helm/helm_get_test.go
create mode 100644 api/http/handler/helm/helm_history.go
create mode 100644 api/http/handler/helm/helm_history_test.go
create mode 100644 api/http/handler/kubernetes/describe.go
delete mode 100644 app/react/kubernetes/HelmView/.keep
delete mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmDetailsWidget.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/actions.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/helper.ts
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/index.ts
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/name.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/resourceType.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/statusMessage.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/queries/useDescribeResource.ts
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/types.ts
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx
create mode 100644 pkg/libhelm/options/history_options.go
create mode 100644 pkg/libhelm/sdk/get.go
create mode 100644 pkg/libhelm/sdk/get_test.go
create mode 100644 pkg/libhelm/sdk/history.go
create mode 100644 pkg/libhelm/sdk/history_test.go
create mode 100644 pkg/libhelm/sdk/resources.go
create mode 100644 pkg/libhelm/sdk/resources_test.go
create mode 100644 pkg/libkubectl/client.go
create mode 100644 pkg/libkubectl/client_test.go
create mode 100644 pkg/libkubectl/describe.go
diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go
index b776fe168..e87e98410 100644
--- a/api/http/handler/helm/handler.go
+++ b/api/http/handler/helm/handler.go
@@ -54,6 +54,14 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/{id}/kubernetes/helm",
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
+ // `helm get all [RELEASE_NAME]`
+ h.Handle("/{id}/kubernetes/helm/{release}",
+ httperror.LoggerHandler(h.helmGet)).Methods(http.MethodGet)
+
+ // `helm history [RELEASE_NAME]`
+ h.Handle("/{id}/kubernetes/helm/{release}/history",
+ httperror.LoggerHandler(h.helmGetHistory)).Methods(http.MethodGet)
+
return h
}
diff --git a/api/http/handler/helm/helm_get.go b/api/http/handler/helm/helm_get.go
new file mode 100644
index 000000000..bd8ea3e60
--- /dev/null
+++ b/api/http/handler/helm/helm_get.go
@@ -0,0 +1,67 @@
+package helm
+
+import (
+ "net/http"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ _ "github.com/portainer/portainer/pkg/libhelm/release"
+ httperror "github.com/portainer/portainer/pkg/libhttp/error"
+ "github.com/portainer/portainer/pkg/libhttp/request"
+ "github.com/portainer/portainer/pkg/libhttp/response"
+)
+
+// @id HelmGet
+// @summary Get a helm release
+// @description Get details of a helm release by release name
+// @description **Access policy**: authenticated
+// @tags helm
+// @security ApiKeyAuth || jwt
+// @produce json
+// @param id path int true "Environment(Endpoint) identifier"
+// @param name path string true "Helm release name"
+// @param namespace query string false "specify an optional namespace"
+// @param showResources query boolean false "show resources of the release"
+// @param revision query int false "specify an optional revision"
+// @success 200 {object} release.Release "Success"
+// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
+// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
+// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
+// @failure 404 "Unable to find an environment with the specified identifier."
+// @failure 500 "Server error occurred while attempting to retrieve the release."
+// @router /endpoints/{id}/kubernetes/helm/{name} [get]
+func (handler *Handler) helmGet(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+ release, err := request.RetrieveRouteVariableValue(r, "release")
+ if err != nil {
+ return httperror.BadRequest("No release specified", err)
+ }
+
+ clusterAccess, httperr := handler.getHelmClusterAccess(r)
+ if httperr != nil {
+ return httperr
+ }
+
+ // build the get options
+ getOpts := options.GetOptions{
+ KubernetesClusterAccess: clusterAccess,
+ Name: release,
+ }
+ namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
+ // optional namespace. The library defaults to "default"
+ if namespace != "" {
+ getOpts.Namespace = namespace
+ }
+ showResources, _ := request.RetrieveBooleanQueryParameter(r, "showResources", true)
+ getOpts.ShowResources = showResources
+ revision, _ := request.RetrieveNumericQueryParameter(r, "revision", true)
+ // optional revision. The library defaults to the latest revision if not specified
+ if revision > 0 {
+ getOpts.Revision = revision
+ }
+
+ releases, err := handler.helmPackageManager.Get(getOpts)
+ if err != nil {
+ return httperror.InternalServerError("Helm returned an error", err)
+ }
+
+ return response.JSON(w, releases)
+}
diff --git a/api/http/handler/helm/helm_get_test.go b/api/http/handler/helm/helm_get_test.go
new file mode 100644
index 000000000..2dad6ef59
--- /dev/null
+++ b/api/http/handler/helm/helm_get_test.go
@@ -0,0 +1,66 @@
+package helm
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/datastore"
+ "github.com/portainer/portainer/api/exec/exectest"
+ "github.com/portainer/portainer/api/http/security"
+ "github.com/portainer/portainer/api/internal/testhelpers"
+ helper "github.com/portainer/portainer/api/internal/testhelpers"
+ "github.com/portainer/portainer/api/jwt"
+ "github.com/portainer/portainer/api/kubernetes"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/portainer/portainer/pkg/libhelm/test"
+
+ "github.com/segmentio/encoding/json"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_helmGet(t *testing.T) {
+ is := assert.New(t)
+
+ _, store := datastore.MustNewTestStore(t, true, true)
+
+ err := store.Endpoint().Create(&portainer.Endpoint{ID: 1})
+ is.NoError(err, "Error creating environment")
+
+ err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
+ is.NoError(err, "Error creating a user")
+
+ jwtService, err := jwt.NewService("1h", store)
+ is.NoError(err, "Error initiating jwt service")
+
+ kubernetesDeployer := exectest.NewKubernetesDeployer()
+ helmPackageManager := test.NewMockHelmPackageManager()
+ kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
+ h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
+
+ is.NotNil(h, "Handler should not fail")
+
+ // Install a single chart, to be retrieved by the handler
+ options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
+ h.helmPackageManager.Upgrade(options)
+
+ t.Run("helmGet sucessfuly retrieves helm release", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"?namespace="+options.Namespace, nil)
+ ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
+ req = req.WithContext(ctx)
+ testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ data := release.Release{}
+ body, err := io.ReadAll(rr.Body)
+ is.NoError(err, "ReadAll should not return error")
+ json.Unmarshal(body, &data)
+ is.Equal(http.StatusOK, rr.Code, "Status should be 200")
+ is.Equal("nginx-1", data.Name)
+ })
+}
diff --git a/api/http/handler/helm/helm_history.go b/api/http/handler/helm/helm_history.go
new file mode 100644
index 000000000..68169bcd4
--- /dev/null
+++ b/api/http/handler/helm/helm_history.go
@@ -0,0 +1,58 @@
+package helm
+
+import (
+ "net/http"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ _ "github.com/portainer/portainer/pkg/libhelm/release"
+ httperror "github.com/portainer/portainer/pkg/libhttp/error"
+ "github.com/portainer/portainer/pkg/libhttp/request"
+ "github.com/portainer/portainer/pkg/libhttp/response"
+)
+
+// @id HelmGetHistory
+// @summary Get a historical list of releases
+// @description Get a historical list of releases by release name
+// @description **Access policy**: authenticated
+// @tags helm
+// @security ApiKeyAuth || jwt
+// @produce json
+// @param id path int true "Environment(Endpoint) identifier"
+// @param name path string true "Helm release name"
+// @param namespace query string false "specify an optional namespace"
+// @success 200 {array} release.Release "Success"
+// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
+// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
+// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
+// @failure 404 "Unable to find an environment with the specified identifier."
+// @failure 500 "Server error occurred while attempting to retrieve the historical list of releases."
+// @router /endpoints/{id}/kubernetes/helm/{release}/history [get]
+func (handler *Handler) helmGetHistory(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+ release, err := request.RetrieveRouteVariableValue(r, "release")
+ if err != nil {
+ return httperror.BadRequest("No release specified", err)
+ }
+
+ clusterAccess, httperr := handler.getHelmClusterAccess(r)
+ if httperr != nil {
+ return httperr
+ }
+
+ historyOptions := options.HistoryOptions{
+ KubernetesClusterAccess: clusterAccess,
+ Name: release,
+ }
+
+ // optional namespace. The library defaults to "default"
+ namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
+ if namespace != "" {
+ historyOptions.Namespace = namespace
+ }
+
+ releases, err := handler.helmPackageManager.GetHistory(historyOptions)
+ if err != nil {
+ return httperror.InternalServerError("Helm returned an error", err)
+ }
+
+ return response.JSON(w, releases)
+}
diff --git a/api/http/handler/helm/helm_history_test.go b/api/http/handler/helm/helm_history_test.go
new file mode 100644
index 000000000..10fadfb1f
--- /dev/null
+++ b/api/http/handler/helm/helm_history_test.go
@@ -0,0 +1,67 @@
+package helm
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/datastore"
+ "github.com/portainer/portainer/api/exec/exectest"
+ "github.com/portainer/portainer/api/http/security"
+ "github.com/portainer/portainer/api/internal/testhelpers"
+ helper "github.com/portainer/portainer/api/internal/testhelpers"
+ "github.com/portainer/portainer/api/jwt"
+ "github.com/portainer/portainer/api/kubernetes"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/portainer/portainer/pkg/libhelm/test"
+
+ "github.com/segmentio/encoding/json"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_helmGetHistory(t *testing.T) {
+ is := assert.New(t)
+
+ _, store := datastore.MustNewTestStore(t, true, true)
+
+ err := store.Endpoint().Create(&portainer.Endpoint{ID: 1})
+ is.NoError(err, "Error creating environment")
+
+ err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
+ is.NoError(err, "Error creating a user")
+
+ jwtService, err := jwt.NewService("1h", store)
+ is.NoError(err, "Error initiating jwt service")
+
+ kubernetesDeployer := exectest.NewKubernetesDeployer()
+ helmPackageManager := test.NewMockHelmPackageManager()
+ kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
+ h := NewHandler(helper.NewTestRequestBouncer(), store, jwtService, kubernetesDeployer, helmPackageManager, kubeClusterAccessService)
+
+ is.NotNil(h, "Handler should not fail")
+
+ // Install a single chart, to be retrieved by the handler
+ options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"}
+ h.helmPackageManager.Upgrade(options)
+
+ t.Run("helmGetHistory sucessfuly retrieves helm release history", func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm/"+options.Name+"/history?namespace="+options.Namespace, nil)
+ ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
+ req = req.WithContext(ctx)
+ testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken")
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ data := []release.Release{}
+ body, err := io.ReadAll(rr.Body)
+ is.NoError(err, "ReadAll should not return error")
+ json.Unmarshal(body, &data)
+ is.Equal(http.StatusOK, rr.Code, "Status should be 200")
+ is.Equal(1, len(data))
+ is.Equal("nginx-1", data[0].Name)
+ })
+}
diff --git a/api/http/handler/kubernetes/describe.go b/api/http/handler/kubernetes/describe.go
new file mode 100644
index 000000000..ce8f17418
--- /dev/null
+++ b/api/http/handler/kubernetes/describe.go
@@ -0,0 +1,73 @@
+package kubernetes
+
+import (
+ "net/http"
+
+ httperror "github.com/portainer/portainer/pkg/libhttp/error"
+ "github.com/portainer/portainer/pkg/libhttp/request"
+ "github.com/portainer/portainer/pkg/libhttp/response"
+ "github.com/portainer/portainer/pkg/libkubectl"
+ "github.com/rs/zerolog/log"
+)
+
+type describeResourceResponse struct {
+ Describe string `json:"describe"`
+}
+
+// @id DescribeResource
+// @summary Get a description of a kubernetes resource
+// @description Get a description of a kubernetes resource.
+// @description **Access policy**: Authenticated user.
+// @tags kubernetes
+// @security ApiKeyAuth || jwt
+// @produce json
+// @param id path int true "Environment identifier"
+// @param name query string true "Resource name"
+// @param kind query string true "Resource kind"
+// @param namespace query string false "Namespace"
+// @success 200 {object} describeResourceResponse "Success"
+// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
+// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
+// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
+// @failure 404 "Unable to find an environment with the specified identifier."
+// @failure 500 "Server error occurred while attempting to retrieve resource description"
+// @router /kubernetes/{id}/describe [get]
+func (handler *Handler) describeResource(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+ name, err := request.RetrieveQueryParameter(r, "name", false)
+ if err != nil {
+ log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter name")
+ return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter name. Error: ", err)
+ }
+
+ kind, err := request.RetrieveQueryParameter(r, "kind", false)
+ if err != nil {
+ log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter kind")
+ return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter kind. Error: ", err)
+ }
+
+ namespace, err := request.RetrieveQueryParameter(r, "namespace", true)
+ if err != nil {
+ log.Error().Err(err).Str("context", "describeResource").Msg("Invalid query parameter namespace")
+ return httperror.BadRequest("an error occurred during the describeResource operation, invalid query parameter namespace. Error: ", err)
+ }
+
+ // fetches the token and the correct server URL for the endpoint, similar to getHelmClusterAccess
+ libKubectlAccess, err := handler.getLibKubectlAccess(r)
+ if err != nil {
+ return httperror.InternalServerError("an error occurred during the describeResource operation, failed to get libKubectlAccess. Error: ", err)
+ }
+
+ client, err := libkubectl.NewClient(libKubectlAccess, namespace, "", true)
+ if err != nil {
+ log.Error().Err(err).Str("context", "describeResource").Msg("Failed to create kubernetes client")
+ return httperror.InternalServerError("an error occurred during the describeResource operation, failed to create kubernetes client. Error: ", err)
+ }
+
+ out, err := client.Describe(namespace, name, kind)
+ if err != nil {
+ log.Error().Err(err).Str("context", "describeResource").Msg("Failed to describe kubernetes resource")
+ return httperror.InternalServerError("an error occurred during the describeResource operation, failed to describe kubernetes resource. Error: ", err)
+ }
+
+ return response.JSON(w, describeResourceResponse{Describe: out})
+}
diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go
index a47bd6ed9..cc068e4a4 100644
--- a/api/http/handler/kubernetes/handler.go
+++ b/api/http/handler/kubernetes/handler.go
@@ -15,6 +15,7 @@ import (
"github.com/portainer/portainer/api/kubernetes/cli"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
+ "github.com/portainer/portainer/pkg/libkubectl"
"github.com/rs/zerolog/log"
"github.com/gorilla/mux"
@@ -102,6 +103,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)
endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost)
+ endpointRouter.Handle("/describe", httperror.LoggerHandler(h.describeResource)).Methods(http.MethodGet)
// namespaces
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
@@ -269,3 +271,36 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
+
+func (handler *Handler) getLibKubectlAccess(r *http.Request) (*libkubectl.ClientAccess, error) {
+ tokenData, err := security.RetrieveTokenData(r)
+ if err != nil {
+ return nil, httperror.InternalServerError("Unable to retrieve user authentication token", err)
+ }
+
+ bearerToken, _, err := handler.JwtService.GenerateToken(tokenData)
+ if err != nil {
+ return nil, httperror.Unauthorized("Unauthorized", err)
+ }
+
+ endpoint, err := middlewares.FetchEndpoint(r)
+ if err != nil {
+ return nil, httperror.InternalServerError("Unable to find the Kubernetes endpoint associated to the request.", err)
+ }
+
+ sslSettings, err := handler.DataStore.SSLSettings().Settings()
+ if err != nil {
+ return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
+ }
+
+ hostURL := "localhost"
+ if !sslSettings.SelfSigned {
+ hostURL = r.Host
+ }
+
+ kubeConfigInternal := handler.kubeClusterAccessService.GetClusterDetails(hostURL, endpoint.ID, true)
+ return &libkubectl.ClientAccess{
+ Token: bearerToken,
+ ServerUrl: kubeConfigInternal.ClusterServerURL,
+ }, nil
+}
diff --git a/app/react/components/CodeEditor.tsx b/app/react/components/CodeEditor.tsx
index 206f7fc5b..09cc9591a 100644
--- a/app/react/components/CodeEditor.tsx
+++ b/app/react/components/CodeEditor.tsx
@@ -36,7 +36,7 @@ interface Props extends AutomationTestingProps {
placeholder?: string;
type?: Type;
readonly?: boolean;
- onChange: (value: string) => void;
+ onChange?: (value: string) => void;
value: string;
height?: string;
versions?: number[];
@@ -136,7 +136,7 @@ function schemaValidationExtensions(schema: JSONSchema7) {
export function CodeEditor({
id,
- onChange,
+ onChange = () => {},
placeholder,
readonly,
value,
diff --git a/app/react/components/NavTabs/index.ts b/app/react/components/NavTabs/index.ts
index 20f2d03a6..cacdb47c6 100644
--- a/app/react/components/NavTabs/index.ts
+++ b/app/react/components/NavTabs/index.ts
@@ -1 +1,2 @@
export { NavTabs } from './NavTabs';
+export type { Option } from './NavTabs';
diff --git a/app/react/components/StatusBadge.tsx b/app/react/components/StatusBadge.tsx
index 81b03f6ad..688fcd81f 100644
--- a/app/react/components/StatusBadge.tsx
+++ b/app/react/components/StatusBadge.tsx
@@ -3,6 +3,56 @@ import { AriaAttributes, PropsWithChildren } from 'react';
import { Icon, IconProps } from '@@/Icon';
+export type StatusBadgeType =
+ | 'success'
+ | 'danger'
+ | 'warning'
+ | 'info'
+ | 'successLite'
+ | 'dangerLite'
+ | 'warningLite'
+ | 'mutedLite'
+ | 'infoLite'
+ | 'default';
+
+const typeClasses: Record = {
+ success: clsx(
+ 'text-white bg-success-7',
+ 'th-dark:text-white th-dark:bg-success-9'
+ ),
+ warning: clsx(
+ 'text-white bg-warning-7',
+ 'th-dark:text-white th-dark:bg-warning-9'
+ ),
+ danger: clsx(
+ 'text-white bg-error-7',
+ 'th-dark:text-white th-dark:bg-error-9'
+ ),
+ info: clsx('text-white bg-blue-7', 'th-dark:text-white th-dark:bg-blue-9'),
+ // the lite classes are a bit lighter in light mode and the same in dark mode
+ successLite: clsx(
+ 'text-success-9 bg-success-3',
+ 'th-dark:text-white th-dark:bg-success-9'
+ ),
+ warningLite: clsx(
+ 'text-warning-9 bg-warning-3',
+ 'th-dark:text-white th-dark:bg-warning-9'
+ ),
+ dangerLite: clsx(
+ 'text-error-9 bg-error-3',
+ 'th-dark:text-white th-dark:bg-error-9'
+ ),
+ mutedLite: clsx(
+ 'text-gray-9 bg-gray-3',
+ 'th-dark:text-white th-dark:bg-gray-9'
+ ),
+ infoLite: clsx(
+ 'text-blue-9 bg-blue-3',
+ 'th-dark:text-white th-dark:bg-blue-9'
+ ),
+ default: '',
+};
+
export function StatusBadge({
className,
children,
@@ -12,7 +62,7 @@ export function StatusBadge({
}: PropsWithChildren<
{
className?: string;
- color?: 'success' | 'danger' | 'warning' | 'info' | 'default';
+ color?: StatusBadgeType;
icon?: IconProps['icon'];
} & AriaAttributes
>) {
@@ -21,13 +71,8 @@ export function StatusBadge({
className={clsx(
'inline-flex items-center gap-1 rounded',
'w-fit px-1.5 py-0.5',
- 'text-sm font-medium text-white',
- {
- 'bg-success-7 th-dark:bg-success-9': color === 'success',
- 'bg-warning-7 th-dark:bg-warning-9': color === 'warning',
- 'bg-error-7 th-dark:bg-error-9': color === 'danger',
- 'bg-blue-9': color === 'info',
- },
+ 'text-sm font-medium',
+ typeClasses[color],
className
)}
// eslint-disable-next-line react/jsx-props-no-spreading
diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx
index 4ca608aa3..2d0b2dfa8 100644
--- a/app/react/components/datatables/Datatable.tsx
+++ b/app/react/components/datatables/Datatable.tsx
@@ -70,6 +70,7 @@ export interface Props extends AutomationTestingProps {
getRowCanExpand?(row: Row): boolean;
noWidget?: boolean;
extendTableOptions?: (options: TableOptions) => TableOptions;
+ includeSearch?: boolean;
}
export function Datatable({
@@ -98,6 +99,7 @@ export function Datatable({
totalCount = dataset.length,
isServerSidePagination = false,
extendTableOptions = (value) => value,
+ includeSearch,
}: Props & PaginationProps) {
const pageCount = useMemo(
() => Math.ceil(totalCount / settings.pageSize),
@@ -192,6 +194,7 @@ export function Datatable({
renderTableActions={() => renderTableActions(selectedItems)}
renderTableSettings={() => renderTableSettings(tableInstance)}
data-cy={`${dataCy}-header`}
+ includeSearch={includeSearch}
/>
diff --git a/app/react/components/datatables/DatatableHeader.tsx b/app/react/components/datatables/DatatableHeader.tsx
index bba2cf405..810f13c7a 100644
--- a/app/react/components/datatables/DatatableHeader.tsx
+++ b/app/react/components/datatables/DatatableHeader.tsx
@@ -16,6 +16,7 @@ type Props = {
renderTableActions?(): ReactNode;
description?: ReactNode;
titleId?: string;
+ includeSearch?: boolean;
} & AutomationTestingProps;
export function DatatableHeader({
@@ -28,8 +29,9 @@ export function DatatableHeader({
description,
titleId,
'data-cy': dataCy,
+ includeSearch = !!title,
}: Props) {
- if (!title) {
+ if (!title && !includeSearch) {
return null;
}
@@ -50,12 +52,12 @@ export function DatatableHeader({
return (
- {searchBar}
+ {includeSearch && searchBar}
{tableActions}
{tableTitleSettings}
diff --git a/app/react/components/modals/Modal/Modal.tsx b/app/react/components/modals/Modal/Modal.tsx
index 25f5a75aa..1f2cb729b 100644
--- a/app/react/components/modals/Modal/Modal.tsx
+++ b/app/react/components/modals/Modal/Modal.tsx
@@ -47,10 +47,14 @@ export function Modal({
{children}
diff --git a/app/react/kubernetes/HelmView/.keep b/app/react/kubernetes/HelmView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
index 63b0468eb..c0e6b8d71 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
@@ -6,6 +6,7 @@ import { server, http } from '@/setup-tests/server';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
+import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
import { HelmApplicationView } from './HelmApplicationView';
@@ -22,6 +23,41 @@ vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => mockUseEnvironmentId(),
}));
+mockCodeMirror();
+
+const minimalHelmRelease = {
+ name: 'test-release',
+ version: '1',
+ namespace: 'default',
+ chart: {
+ metadata: {
+ name: 'test-chart',
+ // appVersion: '1.0.0', // can be missing for a minimal release
+ version: '2.2.2',
+ },
+ },
+ info: {
+ status: 'deployed',
+ // notes: 'This is a test note', // can be missing for a minimal release
+ },
+ manifest: 'This is a test manifest',
+};
+
+const helmReleaseWithAdditionalDetails = {
+ ...minimalHelmRelease,
+ info: {
+ ...minimalHelmRelease.info,
+ notes: 'This is a test note',
+ },
+ chart: {
+ ...minimalHelmRelease.chart,
+ metadata: {
+ ...minimalHelmRelease.chart.metadata,
+ appVersion: '1.0.0',
+ },
+ },
+};
+
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
@@ -40,47 +76,52 @@ describe('HelmApplicationView', () => {
namespace: 'default',
},
});
-
- // Set up default mock API responses
- server.use(
- http.get('/api/endpoints/3/kubernetes/helm', () =>
- HttpResponse.json([
- {
- name: 'test-release',
- chart: 'test-chart-1.0.0',
- app_version: '1.0.0',
- },
- ])
- )
- );
});
- it('should display helm release details when data is loaded', async () => {
- renderComponent();
+ it('should display helm release details for minimal release when data is loaded', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ server.use(
+ http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
+ HttpResponse.json(minimalHelmRelease)
+ )
+ );
+
+ const { findByText, findAllByText } = renderComponent();
// Check for the page header
- expect(await screen.findByText('Helm details')).toBeInTheDocument();
+ expect(await findByText('Helm details')).toBeInTheDocument();
- // Check for the release details
- expect(await screen.findByText('Release')).toBeInTheDocument();
-
- // Check for the table content
- expect(await screen.findByText('Name')).toBeInTheDocument();
- expect(await screen.findByText('Chart')).toBeInTheDocument();
- expect(await screen.findByText('App version')).toBeInTheDocument();
+ // Check for the badge content
+ expect(await findByText(/Namespace/)).toBeInTheDocument();
+ expect(await findByText(/Chart version:/)).toBeInTheDocument();
+ expect(await findByText(/Chart:/)).toBeInTheDocument();
+ expect(await findByText(/Revision/)).toBeInTheDocument();
// Check for the actual values
- expect(await screen.findByTestId('k8sAppDetail-appName')).toHaveTextContent(
- 'test-release'
- );
- expect(await screen.findByText('test-chart-1.0.0')).toBeInTheDocument();
- expect(await screen.findByText('1.0.0')).toBeInTheDocument();
+ expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
+ expect(await findAllByText(/test-chart/)).toHaveLength(2);
+
+ // There shouldn't be a notes tab when there are no notes
+ expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
+
+ // There shouldn't be an app version badge when it's missing
+ expect(screen.queryByText(/App version/)).not.toBeInTheDocument();
+
+ // Ensure there are no console errors
+ // eslint-disable-next-line no-console
+ expect(console.error).not.toHaveBeenCalled();
+
+ // Restore console.error
+ vi.spyOn(console, 'error').mockRestore();
});
it('should display error message when API request fails', async () => {
// Mock API failure
server.use(
- http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.error())
+ http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
+ HttpResponse.error()
+ )
);
// Mock console.error to prevent test output pollution
@@ -97,23 +138,20 @@ describe('HelmApplicationView', () => {
vi.spyOn(console, 'error').mockRestore();
});
- it('should display error message when release is not found', async () => {
- // Mock empty response (no releases found)
+ it('should display additional details when available in helm release', async () => {
server.use(
- http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.json([]))
+ http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
+ HttpResponse.json(helmReleaseWithAdditionalDetails)
+ )
);
- // Mock console.error to prevent test output pollution
- vi.spyOn(console, 'error').mockImplementation(() => {});
+ const { findByText } = renderComponent();
- renderComponent();
+ // Check for the notes tab when notes are available
+ expect(await findByText(/Notes/)).toBeInTheDocument();
- // Wait for the error message to appear
- expect(
- await screen.findByText('Failed to load Helm application details')
- ).toBeInTheDocument();
-
- // Restore console.error
- vi.spyOn(console, 'error').mockRestore();
+ // Check for the app version badge when it's available
+ expect(await findByText(/App version/)).toBeInTheDocument();
+ expect(await findByText('1.0.0', { exact: false })).toBeInTheDocument();
});
});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
index fa02574cc..2fc5fb601 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
@@ -1,12 +1,21 @@
import { useCurrentStateAndParams } from '@uirouter/react';
+import helm from '@/assets/ico/vendor/helm.svg?c';
import { PageHeader } from '@/react/components/PageHeader';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { EnvironmentId } from '@/react/portainer/environments/types';
-import { HelmDetailsWidget } from './HelmDetailsWidget';
+import { WidgetTitle, WidgetBody, Widget, Loading } from '@@/Widget';
+import { Card } from '@@/Card';
+import { Alert } from '@@/Alert';
+
+import { HelmSummary } from './HelmSummary';
+import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
+import { useHelmRelease } from './queries/useHelmRelease';
export function HelmApplicationView() {
+ const environmentId = useEnvironmentId();
const { params } = useCurrentStateAndParams();
-
const { name, namespace } = params;
return (
@@ -22,9 +31,58 @@ export function HelmApplicationView() {
>
);
}
+
+type HelmDetailsProps = {
+ name: string;
+ namespace: string;
+ environmentId: EnvironmentId;
+};
+
+function HelmDetails({ name, namespace, environmentId }: HelmDetailsProps) {
+ const {
+ data: release,
+ isInitialLoading,
+ isError,
+ } = useHelmRelease(environmentId, name, namespace, {
+ showResources: true,
+ });
+
+ if (isInitialLoading) {
+ return
;
+ }
+
+ if (isError) {
+ return (
+
+ );
+ }
+
+ if (!release) {
+ return
;
+ }
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmDetailsWidget.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmDetailsWidget.tsx
deleted file mode 100644
index 97703e0ff..000000000
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmDetailsWidget.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import {
- Loading,
- Widget,
- WidgetBody,
- WidgetTitle,
-} from '@/react/components/Widget';
-import helm from '@/assets/ico/vendor/helm.svg?c';
-import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
-
-import { Alert } from '@@/Alert';
-
-import { useHelmRelease } from './queries/useHelmRelease';
-
-interface HelmDetailsWidgetProps {
- name: string;
- namespace: string;
-}
-
-export function HelmDetailsWidget({ name, namespace }: HelmDetailsWidgetProps) {
- const environmentId = useEnvironmentId();
-
- const {
- data: release,
- isInitialLoading,
- isError,
- } = useHelmRelease(environmentId, name, namespace);
-
- return (
-
-
-
- {isInitialLoading && }
-
- {isError && (
-
- )}
-
- {!isInitialLoading && !isError && release && (
-
-
-
- Name
-
- {release.name}
-
-
-
- Chart
- {release.chart}
-
-
- App version
- {release.app_version}
-
-
-
- )}
-
-
- );
-}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
new file mode 100644
index 000000000..29f9e07d8
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
@@ -0,0 +1,111 @@
+import { Badge } from '@/react/components/Badge';
+
+import { Alert } from '@@/Alert';
+
+import { HelmRelease } from '../types';
+
+interface Props {
+ release: HelmRelease;
+}
+
+export enum DeploymentStatus {
+ DEPLOYED = 'deployed',
+ FAILED = 'failed',
+ PENDING = 'pending-install',
+ PENDINGUPGRADE = 'pending-upgrade',
+ PENDINGROLLBACK = 'pending-rollback',
+ SUPERSEDED = 'superseded',
+ UNINSTALLED = 'uninstalled',
+ UNINSTALLING = 'uninstalling',
+}
+
+export function HelmSummary({ release }: Props) {
+ const isSuccess =
+ release.info?.status === DeploymentStatus.DEPLOYED ||
+ release.info?.status === DeploymentStatus.SUPERSEDED;
+
+ return (
+
+
+
+
+ {getText(release.info?.status)}
+
+
+
+ {!!release.namespace && Namespace: {release.namespace} }
+ {!!release.version && Revision: #{release.version} }
+ {!!release.chart?.metadata?.name && (
+ Chart: {release.chart.metadata.name}
+ )}
+ {!!release.chart?.metadata?.appVersion && (
+ App version: {release.chart.metadata.appVersion}
+ )}
+ {!!release.chart?.metadata?.version && (
+
+ Chart version: {release.chart.metadata.name}-
+ {release.chart.metadata.version}
+
+ )}
+
+ {!!release.info?.description && !isSuccess && (
+
+ {release.info?.description}
+
+ )}
+
+
+ );
+}
+
+function getAlertColor(status?: string) {
+ switch (status?.toLowerCase()) {
+ case DeploymentStatus.DEPLOYED:
+ return 'success';
+ case DeploymentStatus.FAILED:
+ return 'error';
+ case DeploymentStatus.PENDING:
+ case DeploymentStatus.PENDINGUPGRADE:
+ case DeploymentStatus.PENDINGROLLBACK:
+ case DeploymentStatus.UNINSTALLING:
+ return 'warn';
+ case DeploymentStatus.SUPERSEDED:
+ default:
+ return 'info';
+ }
+}
+
+function getStatusColor(status?: string) {
+ switch (status?.toLowerCase()) {
+ case DeploymentStatus.DEPLOYED:
+ return 'success';
+ case DeploymentStatus.FAILED:
+ return 'danger';
+ case DeploymentStatus.PENDING:
+ case DeploymentStatus.PENDINGUPGRADE:
+ case DeploymentStatus.PENDINGROLLBACK:
+ case DeploymentStatus.UNINSTALLING:
+ return 'warn';
+ case DeploymentStatus.SUPERSEDED:
+ default:
+ return 'info';
+ }
+}
+
+function getText(status?: string) {
+ switch (status?.toLowerCase()) {
+ case DeploymentStatus.DEPLOYED:
+ return 'Deployed';
+ case DeploymentStatus.FAILED:
+ return 'Failed';
+ case DeploymentStatus.PENDING:
+ case DeploymentStatus.PENDINGUPGRADE:
+ case DeploymentStatus.PENDINGROLLBACK:
+ case DeploymentStatus.UNINSTALLING:
+ return 'Pending';
+ case DeploymentStatus.SUPERSEDED:
+ return 'Superseded';
+ default:
+ return 'Unknown';
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx
new file mode 100644
index 000000000..c95886d3f
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx
@@ -0,0 +1,18 @@
+import { CodeEditor } from '@@/CodeEditor';
+
+type Props = {
+ manifest: string;
+};
+
+export function ManifestDetails({ manifest }: Props) {
+ return (
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx
new file mode 100644
index 000000000..74a6135bd
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx
@@ -0,0 +1,9 @@
+import Markdown from 'markdown-to-jsx';
+
+type Props = {
+ notes: string;
+};
+
+export function NotesDetails({ notes }: Props) {
+ return
{notes} ;
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
new file mode 100644
index 000000000..6a954d1fc
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
@@ -0,0 +1,68 @@
+import { useState } from 'react';
+import { compact } from 'lodash';
+
+import { NavTabs, Option } from '@@/NavTabs';
+
+import { HelmRelease } from '../../types';
+
+import { ManifestDetails } from './ManifestDetails';
+import { NotesDetails } from './NotesDetails';
+import { ValuesDetails } from './ValuesDetails';
+import { ResourcesTable } from './ResourcesTable/ResourcesTable';
+
+type Props = {
+ release: HelmRelease;
+};
+
+type Tab = 'values' | 'notes' | 'manifest' | 'resources';
+
+function helmTabs(
+ release: HelmRelease,
+ isUserSupplied: boolean,
+ setIsUserSupplied: (isUserSupplied: boolean) => void
+): Option
[] {
+ return compact([
+ {
+ label: 'Resources',
+ id: 'resources',
+ children: ,
+ },
+ {
+ label: 'Values',
+ id: 'values',
+ children: (
+
+ ),
+ },
+ {
+ label: 'Manifest',
+ id: 'manifest',
+ children: ,
+ },
+ !!release.info?.notes && {
+ label: 'Notes',
+ id: 'notes',
+ children: ,
+ },
+ ]);
+}
+
+export function ReleaseTabs({ release }: Props) {
+ const [tab, setTab] = useState('resources');
+ // state is here so that the state isn't lost when the tab changes
+ const [isUserSupplied, setIsUserSupplied] = useState(true);
+
+ return (
+
+ onSelect={setTab}
+ selectedId={tab}
+ type="pills"
+ justified
+ options={helmTabs(release, isUserSupplied, setIsUserSupplied)}
+ />
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx
new file mode 100644
index 000000000..a1510aef8
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx
@@ -0,0 +1,123 @@
+import { render, screen } from '@testing-library/react';
+
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+
+import { DescribeModal } from './DescribeModal';
+
+const mockUseDescribeResource = vi.fn();
+
+vi.mock('yaml-schema', () => ({}));
+vi.mock('./queries/useDescribeResource', () => ({
+ useDescribeResource: (...args: unknown[]) => mockUseDescribeResource(...args),
+}));
+
+function renderComponent({
+ name = 'test-resource',
+ resourceType = 'Deployment',
+ namespace = 'default',
+ onDismiss = vi.fn(),
+} = {}) {
+ const Wrapped = withTestQueryProvider(DescribeModal);
+ return render(
+
+ );
+}
+
+describe('DescribeModal', () => {
+ beforeEach(() => {
+ mockUseDescribeResource.mockReset();
+ });
+
+ it('should display loading state initially', () => {
+ mockUseDescribeResource.mockReturnValue({
+ isLoading: true,
+ data: undefined,
+ isError: false,
+ });
+
+ renderComponent();
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+
+ it('should display resource details when data is loaded successfully', () => {
+ const mockDescribeData = {
+ describe: 'Name: test-resource\nNamespace: default\nStatus: Running',
+ };
+
+ mockUseDescribeResource.mockReturnValue({
+ isLoading: false,
+ data: mockDescribeData,
+ isError: false,
+ });
+
+ renderComponent();
+
+ // Check for modal title
+ expect(screen.getByText('Describe Deployment')).toBeInTheDocument();
+
+ // Check for content
+ const editor = screen.getByTestId('describe-resource');
+ expect(editor).toBeInTheDocument();
+ expect(editor).toHaveTextContent('Name: test-resource');
+ expect(editor).toHaveTextContent('Namespace: default');
+ expect(editor).toHaveTextContent('Status: Running');
+ });
+
+ it('should display error message when query fails', () => {
+ mockUseDescribeResource.mockReturnValue({
+ isLoading: false,
+ data: undefined,
+ isError: true,
+ });
+
+ renderComponent();
+
+ expect(
+ screen.getByText('Error loading resource details')
+ ).toBeInTheDocument();
+ });
+
+ it('should call onDismiss when modal is closed', () => {
+ mockUseDescribeResource.mockReturnValue({
+ isLoading: false,
+ data: { describe: '' },
+ isError: false,
+ });
+
+ const onDismiss = vi.fn();
+ renderComponent({ onDismiss });
+
+ // Find and click the close button
+ const closeButton = screen.getByText('×');
+ closeButton.click();
+
+ expect(onDismiss).toHaveBeenCalled();
+ });
+
+ it('should pass correct parameters to useDescribeResource', () => {
+ mockUseDescribeResource.mockReturnValue({
+ isLoading: true,
+ data: undefined,
+ isError: false,
+ });
+
+ const props = {
+ name: 'my-resource',
+ resourceType: 'Pod',
+ namespace: 'kube-system',
+ };
+
+ renderComponent(props);
+
+ expect(mockUseDescribeResource).toHaveBeenCalledWith(
+ props.name,
+ props.resourceType,
+ props.namespace
+ );
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.tsx
new file mode 100644
index 000000000..f75fbf802
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.tsx
@@ -0,0 +1,57 @@
+import { Alert } from '@@/Alert';
+import { InlineLoader } from '@@/InlineLoader';
+import { Modal } from '@@/modals';
+import { ModalBody } from '@@/modals/Modal/ModalBody';
+import { ModalHeader } from '@@/modals/Modal/ModalHeader';
+import { CodeEditor } from '@@/CodeEditor';
+
+import { useDescribeResource } from './queries/useDescribeResource';
+
+type Props = {
+ name: string;
+ resourceType?: string;
+ namespace?: string;
+ onDismiss: () => void;
+};
+
+export function DescribeModal({
+ name,
+ resourceType,
+ namespace,
+ onDismiss,
+}: Props) {
+ const title = `Describe ${resourceType}`;
+
+ const { data, isLoading, isError } = useDescribeResource(
+ name,
+ resourceType,
+ namespace
+ );
+
+ return (
+
+
+
+ {isLoading ? (
+ Loading...
+ ) : (
+ <>
+ {isError ? (
+
+ Error loading resource details
+
+ ) : (
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx
new file mode 100644
index 000000000..cf0dfde71
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx
@@ -0,0 +1,148 @@
+import { render, screen, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+
+import { withTestRouter } from '@/react/test-utils/withRouter';
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+
+import { GenericResource } from '../../../types';
+
+import { ResourcesTable } from './ResourcesTable';
+
+const successResources = [
+ {
+ kind: 'ValidatingWebhookConfiguration',
+ apiVersion: 'admissionregistration.k8s.io/v1',
+ metadata: {
+ name: 'ingress-nginx-1743063493-admission',
+ uid: 'e5388792-c184-479d-9133-390759c4bded',
+ labels: {
+ 'app.kubernetes.io/name': 'ingress-nginx',
+ },
+ },
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Exists',
+ },
+ },
+ },
+ {
+ kind: 'Deployment',
+ apiVersion: 'apps/v1',
+ metadata: {
+ name: 'ingress-nginx-1743063493-controller2',
+ namespace: 'default',
+ uid: 'dcfe325b-7065-47ed-91e3-47f60301cf2e',
+ labels: {
+ 'app.kubernetes.io/name': 'ingress-nginx',
+ },
+ },
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'MinimumReplicasAvailable',
+ message: 'Deployment has minimum availability.',
+ },
+ },
+ },
+ {
+ kind: 'Pod',
+ apiVersion: 'v1',
+ metadata: {
+ name: 'ingress-nginx-1743063493-controller2-54d8f7d8c5-lsf9p',
+ generateName: 'ingress-nginx-1743063493-controller2-54d8f7d8c5-',
+ namespace: 'default',
+ uid: '7176ad7c-0f83-4a65-a45e-d40076adc302',
+ labels: {
+ 'app.kubernetes.io/name': 'ingress-nginx',
+ 'pod-template-hash': '54d8f7d8c5',
+ },
+ },
+ status: {
+ phase: 'Running',
+ healthSummary: {
+ status: 'Unknown',
+ reason: 'Running',
+ },
+ hostIP: '198.19.249.2',
+ startTime: '2025-03-27T20:39:05Z',
+ },
+ },
+];
+const failedResources = [
+ {
+ kind: 'PodDisruptionBudget',
+ metadata: {
+ name: 'probe-failure-nginx-bad',
+ namespace: 'my-namespace',
+ uid: 'e4e15f7a-9a68-448e-86b3-d74ef29c718c',
+ labels: {
+ 'app.kubernetes.io/name': 'nginx',
+ },
+ },
+ status: {
+ healthSummary: {
+ status: 'Unhealthy',
+ reason: 'InsufficientPods',
+ },
+ },
+ },
+ {
+ kind: 'Service',
+ apiVersion: 'v1',
+ metadata: {
+ name: 'probe-failure-nginx',
+ namespace: 'my-namespace',
+ uid: 'de9cdffc-6af8-43b2-9750-3ac764b25627',
+ labels: {
+ 'app.kubernetes.io/name': 'nginx',
+ },
+ },
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Exists',
+ },
+ },
+ },
+];
+
+function renderResourcesTable(resources: GenericResource[]) {
+ const Wrapped = withTestQueryProvider(withTestRouter(ResourcesTable));
+ return render( );
+}
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe('ResourcesTable', () => {
+ it('should show successful resources, including a link for the deployment and a message', () => {
+ renderResourcesTable(successResources);
+
+ // Check that the deployment is rendered with a link
+ const deploymentLink = screen.getByText(
+ 'ingress-nginx-1743063493-controller2'
+ );
+ expect(deploymentLink).toBeInTheDocument();
+ expect(deploymentLink.closest('a')).toHaveTextContent(
+ 'ingress-nginx-1743063493-controller2'
+ );
+
+ // Check that success badge is rendered
+ const successBadge = screen.getByText('MinimumReplicasAvailable');
+ expect(successBadge).toBeInTheDocument();
+ expect(successBadge.className).toContain('bg-success');
+ });
+
+ it('should show error badges for failed resources', () => {
+ renderResourcesTable(failedResources);
+ expect(screen.getByText('probe-failure-nginx-bad')).toBeInTheDocument();
+
+ // Check for the unhealthy status badge and make sure it has the error styling
+ const errorBadge = screen.getByText('InsufficientPods');
+ expect(errorBadge).toBeInTheDocument();
+ expect(errorBadge.className).toContain('bg-error');
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
new file mode 100644
index 000000000..c8922ca04
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
@@ -0,0 +1,38 @@
+import { Datatable } from '@@/datatables';
+import { createPersistedStore } from '@@/datatables/types';
+import { useTableState } from '@@/datatables/useTableState';
+import { Widget } from '@@/Widget';
+
+import { GenericResource } from '../../../types';
+
+import { columns } from './columns';
+import { useResourceRows } from './useResourceRows';
+
+type Props = {
+ resources: GenericResource[];
+};
+
+const storageKey = 'helm-resources';
+const settingsStore = createPersistedStore(storageKey, 'resourceType');
+
+export function ResourcesTable({ resources }: Props) {
+ const tableState = useTableState(settingsStore, storageKey);
+ const rows = useResourceRows(resources);
+
+ return (
+
+ row.id}
+ data-cy="helm-resources-datatable"
+ />
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/actions.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/actions.tsx
new file mode 100644
index 000000000..434876342
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/actions.tsx
@@ -0,0 +1,46 @@
+import { useState } from 'react';
+import { CellContext } from '@tanstack/react-table';
+import { FileText } from 'lucide-react';
+
+import { Button } from '@@/buttons';
+import { Icon } from '@@/Icon';
+
+import { ResourceRow } from '../types';
+import { DescribeModal } from '../DescribeModal';
+
+import { columnHelper } from './helper';
+
+export const actions = columnHelper.accessor((row) => row.status.label, {
+ header: 'Actions',
+ id: 'actions',
+ cell: Cell,
+ enableSorting: false,
+});
+
+function Cell({ row }: CellContext) {
+ const { describe } = row.original;
+ const [modalOpen, setModalOpen] = useState(false);
+
+ return (
+ <>
+ setModalOpen(true)}
+ className="pl-0 !ml-0"
+ >
+
+ Describe
+
+
+ {modalOpen && (
+ setModalOpen(false)}
+ />
+ )}
+ >
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/helper.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/helper.ts
new file mode 100644
index 000000000..4ba26020e
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/helper.ts
@@ -0,0 +1,5 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { ResourceRow } from '../types';
+
+export const columnHelper = createColumnHelper();
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/index.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/index.ts
new file mode 100644
index 000000000..3f40e40c7
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/index.ts
@@ -0,0 +1,7 @@
+import { name } from './name';
+import { resourceType } from './resourceType';
+import { status } from './status';
+import { statusMessage } from './statusMessage';
+import { actions } from './actions';
+
+export const columns = [name, resourceType, status, statusMessage, actions];
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/name.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/name.tsx
new file mode 100644
index 000000000..332e49f1c
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/name.tsx
@@ -0,0 +1,33 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { Link } from '@@/Link';
+
+import { ResourceRow } from '../types';
+
+import { columnHelper } from './helper';
+
+export const name = columnHelper.accessor((row) => row.name.label, {
+ header: 'Name',
+ cell: Cell,
+ id: 'name',
+});
+
+function Cell({ row }: CellContext) {
+ const { name } = row.original;
+
+ if (name.link && name.link.to) {
+ return (
+
+ {name.label}
+
+ );
+ }
+
+ return name.label;
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/resourceType.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/resourceType.tsx
new file mode 100644
index 000000000..e1df5ffbb
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/resourceType.tsx
@@ -0,0 +1,6 @@
+import { columnHelper } from './helper';
+
+export const resourceType = columnHelper.accessor((row) => row.resourceType, {
+ header: 'Resource type',
+ id: 'resourceType',
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx
new file mode 100644
index 000000000..b9ff43fff
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx
@@ -0,0 +1,18 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { StatusBadge } from '@@/StatusBadge';
+
+import { ResourceRow } from '../types';
+
+import { columnHelper } from './helper';
+
+export const status = columnHelper.accessor((row) => row.status.label, {
+ header: 'Status',
+ id: 'status',
+ cell: Cell,
+});
+
+function Cell({ row }: CellContext) {
+ const { status } = row.original;
+ return {status.label} ;
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/statusMessage.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/statusMessage.tsx
new file mode 100644
index 000000000..5b5b8135e
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/statusMessage.tsx
@@ -0,0 +1,11 @@
+import { columnHelper } from './helper';
+
+export const statusMessage = columnHelper.accessor((row) => row.statusMessage, {
+ header: 'Status message',
+ id: 'statusMessage',
+ cell: ({ row }) => (
+
+ {row.original.statusMessage || '-'}
+
+ ),
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/queries/useDescribeResource.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/queries/useDescribeResource.ts
new file mode 100644
index 000000000..da09a6224
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/queries/useDescribeResource.ts
@@ -0,0 +1,62 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { withGlobalError } from '@/react-tools/react-query';
+
+type DescribeAPIParams = {
+ name: string;
+ kind: string;
+ namespace?: string;
+};
+
+type DescribeResourceResponse = {
+ describe: string;
+};
+
+async function getDescribeResource(
+ environmentId: number,
+ name: string,
+ resourceType?: string,
+ namespace?: string
+) {
+ try {
+ // This should never happen, but to keep the linter happy...
+ if (!name || !resourceType) {
+ throw new Error('Name and kind are required');
+ }
+
+ const params: DescribeAPIParams = {
+ name,
+ namespace,
+ kind: resourceType,
+ };
+
+ const { data } = await axios.get(
+ `kubernetes/${environmentId}/describe`,
+ {
+ params,
+ }
+ );
+ return data;
+ } catch (err) {
+ throw parseAxiosError(err, 'Unable to retrieve resource details');
+ }
+}
+
+export function useDescribeResource(
+ name: string,
+ resourceType?: string,
+ namespace?: string
+) {
+ const environmentId = useEnvironmentId();
+
+ return useQuery(
+ [environmentId, 'kubernetes', 'describe', namespace, resourceType, name],
+ () => getDescribeResource(environmentId, name, resourceType, namespace),
+ {
+ enabled: !!environmentId && !!name && !!resourceType,
+ ...withGlobalError('Enable to retrieve data for resource'),
+ }
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/types.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/types.ts
new file mode 100644
index 000000000..ebc1bc10b
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/types.ts
@@ -0,0 +1,27 @@
+import { StatusBadgeType } from '@@/StatusBadge';
+
+export type ResourceLink = {
+ to?: string;
+ params?: Record;
+};
+
+export type ResourceRow = {
+ // for the table row id
+ id: string;
+ // for the table row name (link to resource if available)
+ name: {
+ label: string;
+ link: ResourceLink | null;
+ };
+ resourceType: string;
+ describe: {
+ name: string;
+ resourceType?: string;
+ namespace?: string;
+ };
+ status: {
+ label: string;
+ type: StatusBadgeType;
+ };
+ statusMessage: string;
+};
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts
new file mode 100644
index 000000000..e5301dc05
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts
@@ -0,0 +1,93 @@
+import { useMemo } from 'react';
+
+import { StatusBadgeType } from '@@/StatusBadge';
+
+import { GenericResource } from '../../../types';
+
+import { ResourceLink, ResourceRow } from './types';
+
+// from defined routes in app/kubernetes/__module.js
+const kindToUrlMap = {
+ Deployment: 'kubernetes.applications.application',
+ DaemonSet: 'kubernetes.applications.application',
+ StatefulSet: 'kubernetes.applications.application',
+ Pod: 'kubernetes.applications.application',
+ Ingress: 'kubernetes.ingresses',
+ ConfigMap: 'kubernetes.configmaps.configmap',
+ Secret: 'kubernetes.secrets.secret',
+ PersistentVolumeClaim: 'kubernetes.volumes.volume',
+};
+
+const statusToColorMap: Record = {
+ Healthy: 'success',
+ Progressing: 'warning',
+ Degraded: 'danger',
+ Failed: 'danger',
+ Unhealthy: 'danger',
+ Unknown: 'mutedLite',
+};
+
+export function useResourceRows(resources: GenericResource[]): ResourceRow[] {
+ return useMemo(() => getResourceRows(resources), [resources]);
+}
+
+function getResourceRows(resources: GenericResource[]): ResourceRow[] {
+ return resources.map(getResourceRow);
+}
+
+function getResourceRow(resource: GenericResource): ResourceRow {
+ const {
+ reason = '',
+ status = '',
+ message = '',
+ } = resource.status.healthSummary || {};
+
+ return {
+ id: `${resource.kind}/${resource.metadata.name}/${resource.metadata.namespace}`,
+ name: {
+ label: resource.metadata.name,
+ link: getResourceLink(resource),
+ },
+ resourceType: resource.kind ?? '-',
+ describe: {
+ name: resource.metadata.name,
+ namespace: resource.metadata.namespace,
+ resourceType: resource.kind,
+ },
+ status: {
+ label: reason ?? 'Unknown',
+ type: statusToColorMap[status] ?? 'default',
+ },
+ statusMessage: message ?? '-',
+ };
+}
+
+function getResourceLink(resource: GenericResource): ResourceLink | null {
+ const { namespace, name } = resource.metadata;
+
+ const to = kindToUrlMap[resource.kind as keyof typeof kindToUrlMap];
+
+ // If the resource kind is not supported, return null
+ if (!to) {
+ return null;
+ }
+
+ // If the resource is not namespaced, return the link to the resource with the name only
+ if (!namespace) {
+ return {
+ to,
+ params: {
+ name,
+ },
+ };
+ }
+
+ // If the resource is namespaced, return the link to the resource with the namespace and name
+ return {
+ to,
+ params: {
+ namespace,
+ name,
+ },
+ };
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx
new file mode 100644
index 000000000..b1f32ca3f
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx
@@ -0,0 +1,44 @@
+import { Checkbox } from '@@/form-components/Checkbox';
+import { CodeEditor } from '@@/CodeEditor';
+
+import { Values } from '../../types';
+
+interface Props {
+ values?: Values;
+ isUserSupplied: boolean;
+ setIsUserSupplied: (isUserSupplied: boolean) => void;
+}
+
+const noValuesMessage = 'No values found';
+
+export function ValuesDetails({
+ values,
+ isUserSupplied,
+ setIsUserSupplied,
+}: Props) {
+ return (
+
+ {/* bring in line with the code editor copy button */}
+
+ setIsUserSupplied(!isUserSupplied)}
+ data-cy="values-details-user-supplied"
+ />
+
+
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
index b7cfcb91c..f503aec7f 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
@@ -2,55 +2,33 @@ import { useQuery } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withGlobalError } from '@/react-tools/react-query';
-import PortainerError from 'Portainer/error';
import axios, { parseAxiosError } from '@/portainer/services/axios';
-interface HelmRelease {
- name: string;
- chart: string;
- app_version: string;
-}
-/**
- * List all helm releases based on passed in options
- * @param environmentId - Environment ID
- * @param options - Options for filtering releases
- * @returns List of helm releases
- */
-export async function listReleases(
- environmentId: EnvironmentId,
- options: {
- namespace?: string;
- filter?: string;
- selector?: string;
- output?: string;
- } = {}
-): Promise {
- try {
- const { namespace, filter, selector, output } = options;
- const url = `endpoints/${environmentId}/kubernetes/helm`;
- const { data } = await axios.get(url, {
- params: { namespace, filter, selector, output },
- });
- return data;
- } catch (e) {
- throw parseAxiosError(e as Error, 'Unable to retrieve release list');
- }
-}
+import { HelmRelease } from '../../types';
/**
* React hook to fetch a specific Helm release
*/
-export function useHelmRelease(
+export function useHelmRelease(
environmentId: EnvironmentId,
name: string,
- namespace: string
+ namespace: string,
+ options: {
+ select?: (data: HelmRelease) => T;
+ showResources?: boolean;
+ } = {}
) {
return useQuery(
- [environmentId, 'helm', namespace, name],
- () => getHelmRelease(environmentId, name, namespace),
+ [environmentId, 'helm', 'releases', namespace, name, options.showResources],
+ () =>
+ getHelmRelease(environmentId, name, {
+ namespace,
+ showResources: options.showResources,
+ }),
{
- enabled: !!environmentId,
+ enabled: !!environmentId && !!name && !!namespace,
...withGlobalError('Unable to retrieve helm application details'),
+ select: options.select,
}
);
}
@@ -61,23 +39,20 @@ export function useHelmRelease(
async function getHelmRelease(
environmentId: EnvironmentId,
name: string,
- namespace: string
-): Promise {
+ params: {
+ namespace: string;
+ showResources?: boolean;
+ }
+) {
try {
- const releases = await listReleases(environmentId, {
- filter: `^${name}$`,
- namespace,
- });
-
- if (releases.length > 0) {
- return releases[0];
- }
-
- throw new PortainerError(`Release ${name} not found`);
- } catch (err) {
- throw new PortainerError(
- 'Unable to retrieve helm application details',
- err as Error
+ const { data } = await axios.get(
+ `endpoints/${environmentId}/kubernetes/helm/${name}`,
+ {
+ params,
+ }
);
+ return data;
+ } catch (err) {
+ throw parseAxiosError(err, 'Unable to retrieve helm application details');
}
}
diff --git a/app/react/kubernetes/helm/types.ts b/app/react/kubernetes/helm/types.ts
index 8b043a080..49445fa30 100644
--- a/app/react/kubernetes/helm/types.ts
+++ b/app/react/kubernetes/helm/types.ts
@@ -1,3 +1,79 @@
+interface ResourceStatus {
+ phase?: string;
+ reason?: string;
+ message?: string;
+ healthSummary?: {
+ status: string;
+ reason: string;
+ message?: string;
+ };
+}
+
+// A resource has a bunch of common fields that are shared by all kubernetes resources, so can be used when we're unsure about the resource type we have
+export interface GenericResource {
+ apiVersion?: string;
+ kind?: string;
+ metadata: {
+ name: string;
+ namespace?: string;
+ };
+ status: ResourceStatus;
+}
+
+export interface HelmRelease {
+ /** The name of the release */
+ name: string;
+ /** Information about the release */
+ info?: {
+ status?: string;
+ notes?: string;
+ description?: string;
+ resources?: GenericResource[];
+ };
+ /** The chart that was released */
+ chart: HelmChart;
+ /** Extra values added to the chart that override the default values */
+ config?: Record;
+ /** String representation of the rendered template */
+ manifest: string;
+ /** All hooks declared for this release */
+ hooks?: unknown[];
+ /** Integer representing the revision of the release */
+ version?: number;
+ /** Kubernetes namespace of the release */
+ namespace?: string;
+ /** Values of the release */
+ values?: Values;
+}
+
+export interface Values {
+ /** User supplied values */
+ userSuppliedValues?: string;
+ /** Computed values */
+ computedValues?: string;
+}
+
+export interface HelmChart {
+ /** Raw contents of the files originally contained in the chart archive. Only used in special cases like `helm show values` */
+ raw?: unknown[];
+ /** Contents of the Chartfile */
+ metadata?: {
+ name?: string;
+ version?: string;
+ appVersion?: string;
+ };
+ /** Contents of Chart.lock */
+ lock?: unknown;
+ /** Templates for this chart */
+ templates?: unknown[];
+ /** Default config for this chart */
+ values?: Record;
+ /** Optional JSON schema for imposing structure on Values */
+ schema?: unknown;
+ /** Miscellaneous files in a chart archive (e.g. README, LICENSE) */
+ files?: unknown[];
+}
+
export interface Chart extends HelmChartResponse {
repo: string;
}
diff --git a/go.mod b/go.mod
index 4a28e80a1..ad6c86799 100644
--- a/go.mod
+++ b/go.mod
@@ -54,11 +54,14 @@ require (
golang.org/x/oauth2 v0.23.0
golang.org/x/sync v0.11.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.1
k8s.io/api v0.32.1
k8s.io/apimachinery v0.32.1
+ k8s.io/cli-runtime v0.32.1
k8s.io/client-go v0.32.1
+ k8s.io/kubectl v0.32.1
k8s.io/metrics v0.32.1
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
)
@@ -129,6 +132,7 @@ require (
github.com/emirpasic/gods v1.18.1 // indirect
github.com/evanphx/json-patch v5.9.0+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
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsevents v0.2.0 // indirect
@@ -288,14 +292,11 @@ require (
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
- gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/apiextensions-apiserver v0.32.1 // indirect
k8s.io/apiserver v0.32.1 // indirect
- k8s.io/cli-runtime v0.32.1 // indirect
k8s.io/component-base v0.32.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
- k8s.io/kubectl v0.32.1 // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
oras.land/oras-go v1.2.5 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
diff --git a/go.sum b/go.sum
index 8fbb68613..110e5eb4b 100644
--- a/go.sum
+++ b/go.sum
@@ -229,6 +229,8 @@ github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lSh
github.com/evanphx/json-patch v5.9.0+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=
+github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -451,6 +453,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
+github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY=
+github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y=
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
diff --git a/package.json b/package.json
index b67cd56ff..e0cc82463 100644
--- a/package.json
+++ b/package.json
@@ -106,6 +106,7 @@
"json-schema": "^0.4.0",
"lodash": "^4.17.21",
"lucide-react": "^0.468.0",
+ "markdown-to-jsx": "^7.7.4",
"moment": "^2.29.1",
"moment-timezone": "^0.5.40",
"mustache": "^4.2.0",
diff --git a/pkg/libhelm/options/get_options.go b/pkg/libhelm/options/get_options.go
index a65cd75a7..fcac76ab9 100644
--- a/pkg/libhelm/options/get_options.go
+++ b/pkg/libhelm/options/get_options.go
@@ -1,21 +1,11 @@
package options
-// releaseResource are the supported `helm get` sub-commands
-// to see all available sub-commands run `helm get --help`
-type releaseResource string
-
-const (
- GetAll releaseResource = "all"
- GetHooks releaseResource = "hooks"
- GetManifest releaseResource = "manifest"
- GetNotes releaseResource = "notes"
- GetValues releaseResource = "values"
-)
-
type GetOptions struct {
- Name string
- Namespace string
- ReleaseResource releaseResource
+ Name string
+ Namespace string
+ // ShowResources indicates whether to display the resources of the named release
+ ShowResources bool
+ Revision int
KubernetesClusterAccess *KubernetesClusterAccess
Env []string
diff --git a/pkg/libhelm/options/history_options.go b/pkg/libhelm/options/history_options.go
new file mode 100644
index 000000000..76bb02e47
--- /dev/null
+++ b/pkg/libhelm/options/history_options.go
@@ -0,0 +1,9 @@
+package options
+
+type HistoryOptions struct {
+ Name string
+ Namespace string
+ KubernetesClusterAccess *KubernetesClusterAccess
+
+ Env []string
+}
diff --git a/pkg/libhelm/release/release.go b/pkg/libhelm/release/release.go
index bd1e32917..56888291c 100644
--- a/pkg/libhelm/release/release.go
+++ b/pkg/libhelm/release/release.go
@@ -1,6 +1,9 @@
package release
-import "github.com/portainer/portainer/pkg/libhelm/time"
+import (
+ "github.com/portainer/portainer/pkg/libhelm/time"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
// Release is the struct that holds the information for a helm release.
// The struct definitions have been copied from the official Helm Golang client/library.
@@ -14,7 +17,7 @@ type ReleaseElement struct {
Updated string `json:"updated"`
Status string `json:"status"`
Chart string `json:"chart"`
- AppVersion string `json:"app_version"`
+ AppVersion string `json:"appVersion"`
}
// Release describes a deployment of a chart, together with the chart
@@ -23,7 +26,7 @@ type Release struct {
// Name is the name of the release
Name string `json:"name,omitempty"`
// Info provides information about a release
- // Info *Info `json:"info,omitempty"`
+ Info *Info `json:"info,omitempty"`
// Chart is the chart that was released.
Chart Chart `json:"chart,omitempty"`
// Config is the set of extra Values added to the chart.
@@ -40,6 +43,13 @@ type Release struct {
// Labels of the release.
// Disabled encoding into Json cause labels are stored in storage driver metadata field.
Labels map[string]string `json:"-"`
+ // Values are the values used to deploy the chart.
+ Values Values `json:"values,omitempty"`
+}
+
+type Values struct {
+ UserSuppliedValues string `json:"userSuppliedValues,omitempty"`
+ ComputedValues string `json:"computedValues,omitempty"`
}
// Chart is a helm package that contains metadata, a default config, zero or more
@@ -183,6 +193,8 @@ type Info struct {
Status Status `json:"status,omitempty"`
// Contains the rendered templates/NOTES.txt if available
Notes string `json:"notes,omitempty"`
+ // Resources is the list of resources that are part of the release
+ Resources []*unstructured.Unstructured `json:"resources,omitempty"`
}
// Status is the status of a release
diff --git a/pkg/libhelm/sdk/get.go b/pkg/libhelm/sdk/get.go
new file mode 100644
index 000000000..51a5c3bce
--- /dev/null
+++ b/pkg/libhelm/sdk/get.go
@@ -0,0 +1,100 @@
+package sdk
+
+import (
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/rs/zerolog/log"
+ "helm.sh/helm/v3/pkg/action"
+ sdkrelease "helm.sh/helm/v3/pkg/release"
+)
+
+// Get implements the HelmPackageManager interface by using the Helm SDK to get a release.
+// It returns a Release.
+func (hspm *HelmSDKPackageManager) Get(getOptions options.GetOptions) (*release.Release, error) {
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("namespace", getOptions.Namespace).
+ Str("name", getOptions.Name).
+ Msg("Get Helm release")
+
+ actionConfig := new(action.Configuration)
+ err := hspm.initActionConfig(actionConfig, getOptions.Namespace, getOptions.KubernetesClusterAccess)
+
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("namespace", getOptions.Namespace).
+ Err(err).Msg("Failed to initialise helm configuration")
+ return nil, err
+ }
+
+ statusClient, err := hspm.initStatusClient(actionConfig, getOptions)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("namespace", getOptions.Namespace).
+ Err(err).Msg("Failed to initialise helm status client")
+ return nil, err
+ }
+
+ release, err := statusClient.Run(getOptions.Name)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("namespace", getOptions.Namespace).
+ Err(err).Msg("Failed to query helm chart")
+ return nil, err
+ }
+
+ values, err := hspm.getValues(getOptions)
+ if err != nil {
+ // error is already logged in getValuesFromStatus
+ return nil, err
+ }
+
+ return convert(release, values), nil
+}
+
+// Helm status is just an extended helm get command with resources added on (when flagged), so use the status client with the optional show resources flag
+// https://github.com/helm/helm/blob/0199b748aaea3091852d16687c9f9f809061777c/pkg/action/get.go#L40-L47
+// https://github.com/helm/helm/blob/0199b748aaea3091852d16687c9f9f809061777c/pkg/action/status.go#L48-L82
+func (hspm *HelmSDKPackageManager) initStatusClient(actionConfig *action.Configuration, getOptions options.GetOptions) (*action.Status, error) {
+ statusClient := action.NewStatus(actionConfig)
+ statusClient.ShowResources = getOptions.ShowResources
+ if getOptions.Revision > 0 {
+ statusClient.Version = getOptions.Revision
+ }
+
+ return statusClient, nil
+}
+
+func convert(sdkRelease *sdkrelease.Release, values release.Values) *release.Release {
+ resources, err := parseResources(sdkRelease.Info.Resources)
+ if err != nil {
+ log.Warn().
+ Str("context", "HelmClient").
+ Str("namespace", sdkRelease.Namespace).
+ Str("name", sdkRelease.Name).
+ Err(err).Msg("Failed to parse resources")
+ }
+ return &release.Release{
+ Name: sdkRelease.Name,
+ Namespace: sdkRelease.Namespace,
+ Version: sdkRelease.Version,
+ Info: &release.Info{
+ Status: release.Status(sdkRelease.Info.Status),
+ Notes: sdkRelease.Info.Notes,
+ Resources: resources,
+ Description: sdkRelease.Info.Description,
+ },
+ Manifest: sdkRelease.Manifest,
+ Chart: release.Chart{
+ Metadata: &release.Metadata{
+ Name: sdkRelease.Chart.Metadata.Name,
+ Version: sdkRelease.Chart.Metadata.Version,
+ AppVersion: sdkRelease.Chart.Metadata.AppVersion,
+ },
+ },
+ Values: values,
+ }
+}
diff --git a/pkg/libhelm/sdk/get_test.go b/pkg/libhelm/sdk/get_test.go
new file mode 100644
index 000000000..992f5f9fd
--- /dev/null
+++ b/pkg/libhelm/sdk/get_test.go
@@ -0,0 +1,39 @@
+package sdk
+
+import (
+ "testing"
+
+ libhelmrelease "github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/stretchr/testify/assert"
+ "helm.sh/helm/v3/pkg/chart"
+ sdkrelease "helm.sh/helm/v3/pkg/release"
+)
+
+func Test_Convert(t *testing.T) {
+ t.Run("successfully maps a sdk release to a release", func(t *testing.T) {
+ is := assert.New(t)
+
+ release := sdkrelease.Release{
+ Name: "releaseName",
+ Version: 1,
+ Info: &sdkrelease.Info{
+ Status: "deployed",
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "chartName",
+ Version: "chartVersion",
+ AppVersion: "chartAppVersion",
+ },
+ },
+ }
+
+ values := libhelmrelease.Values{
+ UserSuppliedValues: `{"key": "value"}`,
+ ComputedValues: `{"key": "value"}`,
+ }
+
+ result := convert(&release, values)
+ is.Equal(release.Name, result.Name)
+ })
+}
diff --git a/pkg/libhelm/sdk/history.go b/pkg/libhelm/sdk/history.go
new file mode 100644
index 000000000..ba6fa4d5d
--- /dev/null
+++ b/pkg/libhelm/sdk/history.go
@@ -0,0 +1,68 @@
+package sdk
+
+import (
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/portainer/portainer/pkg/libhelm/time"
+ "github.com/rs/zerolog/log"
+ "helm.sh/helm/v3/pkg/action"
+ sdkrelease "helm.sh/helm/v3/pkg/release"
+)
+
+// GetHistory implements the HelmPackageManager interface by using the Helm SDK to get a release.
+// It returns a Release.
+func (hspm *HelmSDKPackageManager) GetHistory(historyOptions options.HistoryOptions) ([]*release.Release, error) {
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("namespace", historyOptions.Namespace).
+ Str("name", historyOptions.Name).
+ Msg("Get Helm history")
+
+ actionConfig := new(action.Configuration)
+ err := hspm.initActionConfig(actionConfig, historyOptions.Namespace, historyOptions.KubernetesClusterAccess)
+
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("namespace", historyOptions.Namespace).
+ Err(err).Msg("Failed to initialise helm configuration")
+ return nil, err
+ }
+
+ historyClient := action.NewHistory(actionConfig)
+ history, err := historyClient.Run(historyOptions.Name)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("namespace", historyOptions.Namespace).
+ Err(err).Msg("Failed to query helm release history")
+ return nil, err
+ }
+
+ var result []*release.Release
+ for _, r := range history {
+ result = append(result, convertHistory(r))
+ }
+
+ return result, nil
+}
+
+func convertHistory(sdkRelease *sdkrelease.Release) *release.Release {
+ return &release.Release{
+ Name: sdkRelease.Name,
+ Namespace: sdkRelease.Namespace,
+ Version: sdkRelease.Version,
+ Info: &release.Info{
+ Status: release.Status(sdkRelease.Info.Status),
+ Notes: sdkRelease.Info.Notes,
+ LastDeployed: time.Time(sdkRelease.Info.LastDeployed),
+ },
+ Chart: release.Chart{
+ Metadata: &release.Metadata{
+ Name: sdkRelease.Chart.Metadata.Name,
+ Version: sdkRelease.Chart.Metadata.Version,
+ AppVersion: sdkRelease.Chart.Metadata.AppVersion,
+ },
+ },
+ }
+}
diff --git a/pkg/libhelm/sdk/history_test.go b/pkg/libhelm/sdk/history_test.go
new file mode 100644
index 000000000..718911697
--- /dev/null
+++ b/pkg/libhelm/sdk/history_test.go
@@ -0,0 +1,33 @@
+package sdk
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "helm.sh/helm/v3/pkg/chart"
+ sdkrelease "helm.sh/helm/v3/pkg/release"
+)
+
+func Test_ConvertHistory(t *testing.T) {
+ t.Run("successfully maps a sdk release to a release", func(t *testing.T) {
+ is := assert.New(t)
+
+ release := sdkrelease.Release{
+ Name: "releaseName",
+ Version: 1,
+ Info: &sdkrelease.Info{
+ Status: "deployed",
+ },
+ Chart: &chart.Chart{
+ Metadata: &chart.Metadata{
+ Name: "chartName",
+ Version: "chartVersion",
+ AppVersion: "chartAppVersion",
+ },
+ },
+ }
+
+ result := convertHistory(&release)
+ is.Equal(release.Name, result.Name)
+ })
+}
diff --git a/pkg/libhelm/sdk/resources.go b/pkg/libhelm/sdk/resources.go
new file mode 100644
index 000000000..0c1dce52a
--- /dev/null
+++ b/pkg/libhelm/sdk/resources.go
@@ -0,0 +1,291 @@
+package sdk
+
+import (
+ "time"
+
+ "github.com/segmentio/encoding/json"
+
+ "github.com/rs/zerolog/log"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+)
+
+const (
+ Unknown = "Unknown"
+ Healthy = "Healthy"
+ Unhealthy = "Unhealthy"
+ Progressing = "Progressing"
+)
+
+// ResourceStatus represents a generic status for any Kubernetes resource.
+type ResourceStatus struct {
+ // Phase is a simple, high-level summary of where the resource is in its lifecycle.
+ Phase string `json:"phase,omitempty"`
+
+ // HealthSummary represents the summarized health status of the resource
+ HealthSummary *HealthCondition `json:"healthSummary,omitempty"`
+
+ // Reason is a brief CamelCase string containing the reason for the resource's current status.
+ Reason string `json:"reason,omitempty"`
+
+ // Message is a human-readable description of the current status.
+ Message string `json:"message,omitempty"`
+}
+
+// HealthCondition represents a summarized health condition for a resource
+type HealthCondition struct {
+ Status string `json:"status,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+// parseResources returns a list of resources with additional status information, in a consistent format.
+func parseResources(resourceTypesLists map[string][]runtime.Object) ([]*unstructured.Unstructured, error) {
+ flattenedResources := flattenResources(resourceTypesLists)
+
+ resourcesInfo := []*unstructured.Unstructured{}
+ for _, resource := range flattenedResources {
+ info, err := getResourceInfo(resource)
+ if err != nil {
+ return nil, err
+ }
+
+ resourcesInfo = append(resourcesInfo, info)
+ }
+
+ return resourcesInfo, nil
+}
+
+func getResourceInfo(obj runtime.Object) (*unstructured.Unstructured, error) {
+ data, err := json.Marshal(obj)
+ if err != nil {
+ return nil, err
+ }
+
+ res := &unstructured.Unstructured{}
+ err = json.Unmarshal(data, res)
+ if err != nil {
+ return nil, err
+ }
+
+ status, conditions, err := extractStatus(res)
+ if err == nil {
+ summarizeStatus(status, conditions, res.GetName(), res.GetNamespace(), err)
+ applyStatusToResource(res, status)
+ }
+
+ // only keep metadata, kind and status (other fields are not needed)
+ res.Object = map[string]any{
+ "metadata": res.Object["metadata"],
+ "kind": res.Object["kind"],
+ "status": res.Object["status"],
+ }
+
+ return res, nil
+}
+
+// extractStatus extracts the status from an unstructured resource
+func extractStatus(res *unstructured.Unstructured) (*ResourceStatus, []metav1.Condition, error) {
+ statusMap, found, err := unstructured.NestedMap(res.Object, "status")
+ if !found || err != nil {
+ return &ResourceStatus{}, nil, nil
+ }
+
+ // Extract basic status fields
+ phase, _, _ := unstructured.NestedString(statusMap, "phase")
+ reason, _, _ := unstructured.NestedString(statusMap, "reason")
+ message, _, _ := unstructured.NestedString(statusMap, "message")
+
+ // Extract conditions for analysis
+ conditions := []metav1.Condition{}
+ conditionsData, found, _ := unstructured.NestedSlice(statusMap, "conditions")
+ if found {
+ for _, condData := range conditionsData {
+ condMap, ok := condData.(map[string]any)
+ if !ok {
+ continue
+ }
+
+ cond := metav1.Condition{}
+ if typeStr, ok := condMap["type"].(string); ok {
+ cond.Type = typeStr
+ }
+ if statusStr, ok := condMap["status"].(string); ok {
+ cond.Status = metav1.ConditionStatus(statusStr)
+ }
+ if reasonStr, ok := condMap["reason"].(string); ok {
+ cond.Reason = reasonStr
+ }
+ if msgStr, ok := condMap["message"].(string); ok {
+ cond.Message = msgStr
+ }
+ if timeStr, ok := condMap["lastTransitionTime"].(string); ok {
+ t, _ := time.Parse(time.RFC3339, timeStr)
+ cond.LastTransitionTime = metav1.Time{Time: t}
+ }
+
+ conditions = append(conditions, cond)
+ }
+ }
+
+ return &ResourceStatus{
+ Phase: phase,
+ Reason: reason,
+ Message: message,
+ }, conditions, nil
+}
+
+// summarizeStatus creates a health summary based on resource status and conditions
+func summarizeStatus(status *ResourceStatus, conditions []metav1.Condition, name string, namespace string, err error) *ResourceStatus {
+ healthSummary := &HealthCondition{
+ Status: Unknown,
+ Reason: status.Reason,
+ Message: status.Message,
+ }
+
+ // Handle error case first
+ if err != nil {
+ healthSummary.Reason = "ErrorGettingStatus"
+ healthSummary.Message = err.Error()
+ status.HealthSummary = healthSummary
+ return status
+ }
+
+ // Handle phase-based status
+ switch status.Phase {
+ case "Error":
+ healthSummary.Status = Unhealthy
+ healthSummary.Reason = status.Phase
+ case "Running":
+ healthSummary.Status = Healthy
+ healthSummary.Reason = status.Phase
+ case "Pending":
+ healthSummary.Status = Progressing
+ healthSummary.Reason = status.Phase
+ case "Failed":
+ healthSummary.Status = Unhealthy
+ healthSummary.Reason = status.Phase
+ case "Available", "Active", "Established", "Bound", "Ready", "Succeeded":
+ healthSummary.Status = Healthy
+ healthSummary.Reason = status.Phase
+ case "":
+ // Empty phase - check conditions or default to "Exists"
+ if len(conditions) > 0 {
+ analyzeConditions(conditions, healthSummary)
+ } else {
+ healthSummary.Status = Healthy
+ healthSummary.Reason = "Exists"
+ }
+ default:
+ log.Warn().
+ Str("context", "HelmClient").
+ Str("namespace", namespace).
+ Str("name", name).
+ Str("phase", status.Phase).
+ Msg("Unhandled status")
+ healthSummary.Reason = status.Phase
+ }
+
+ // Set message from first condition if available
+ if len(conditions) > 0 && healthSummary.Message == "" {
+ healthSummary.Message = conditions[0].Message
+ }
+
+ status.HealthSummary = healthSummary
+ return status
+}
+
+// analyzeConditions determines resource health based on standard condition types
+func analyzeConditions(conditions []metav1.Condition, healthSummary *HealthCondition) {
+ for _, cond := range conditions {
+ switch cond.Type {
+ case "Progressing":
+ if cond.Status == "False" {
+ healthSummary.Status = Unhealthy
+ healthSummary.Reason = cond.Reason
+ } else if cond.Reason != "NewReplicaSetAvailable" {
+ healthSummary.Status = Unknown
+ healthSummary.Reason = cond.Reason
+ }
+ case "Available", "Ready", "DisruptionAllowed", "Established", "NamesAccepted":
+ if healthSummary.Status == Unknown ||
+ (cond.Type == "Established" && healthSummary.Status == Healthy) ||
+ (cond.Type == "NamesAccepted" && healthSummary.Status == Healthy) {
+ if cond.Status == "False" {
+ healthSummary.Status = Unhealthy
+ } else {
+ healthSummary.Status = Healthy
+ }
+ healthSummary.Reason = cond.Reason
+ }
+ case "ContainersReady":
+ if healthSummary.Status == Unknown && cond.Status == "False" {
+ healthSummary.Status = Unhealthy
+ healthSummary.Reason = cond.Reason
+ }
+ }
+ }
+}
+
+// applyStatusToResource applies the typed ResourceStatus back to the unstructured resource
+func applyStatusToResource(res *unstructured.Unstructured, status *ResourceStatus) {
+ statusMap := map[string]any{
+ "phase": status.Phase,
+ "reason": status.Reason,
+ "message": status.Message,
+ }
+
+ if status.HealthSummary != nil {
+ statusMap["healthSummary"] = map[string]any{
+ "status": status.HealthSummary.Status,
+ "reason": status.HealthSummary.Reason,
+ "message": status.HealthSummary.Message,
+ }
+ }
+
+ unstructured.SetNestedMap(res.Object, statusMap, "status")
+}
+
+// flattenResources extracts items from a list resource and convert them to runtime.Objects
+func flattenResources(resourceTypesLists map[string][]runtime.Object) []runtime.Object {
+ flattenedResources := []runtime.Object{}
+
+ for _, resourceTypeList := range resourceTypesLists {
+ for _, resourceItem := range resourceTypeList {
+ // if the resource item is a list, we need to flatten it too e.g. PodList
+ items := extractItemsIfList(resourceItem)
+ if items != nil {
+ flattenedResources = append(flattenedResources, items...)
+ } else {
+ flattenedResources = append(flattenedResources, resourceItem)
+ }
+ }
+ }
+
+ return flattenedResources
+}
+
+// extractItemsIfList extracts items if the resource is a list, or returns nil if not a list
+func extractItemsIfList(resource runtime.Object) []runtime.Object {
+ unstructuredObj, ok := resource.(runtime.Unstructured)
+ if !ok {
+ return nil
+ }
+
+ if !unstructuredObj.IsList() {
+ return nil
+ }
+
+ extractedItems := []runtime.Object{}
+ err := unstructuredObj.EachListItem(func(obj runtime.Object) error {
+ extractedItems = append(extractedItems, obj)
+ return nil
+ })
+
+ if err != nil {
+ return nil
+ }
+
+ return extractedItems
+}
diff --git a/pkg/libhelm/sdk/resources_test.go b/pkg/libhelm/sdk/resources_test.go
new file mode 100644
index 000000000..8bd3b8c21
--- /dev/null
+++ b/pkg/libhelm/sdk/resources_test.go
@@ -0,0 +1,143 @@
+package sdk
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+)
+
+func TestParseResources(t *testing.T) {
+ t.Run("successfully parse single resource", func(t *testing.T) {
+ resourceTypesLists := map[string][]runtime.Object{
+ "v1/Pod(related)": {
+ &unstructured.Unstructured{
+ Object: map[string]any{
+ "apiVersion": "v1",
+ "kind": "Pod",
+ "metadata": map[string]any{
+ "name": "test-pod",
+ "namespace": "default",
+ },
+ "status": map[string]any{
+ "phase": "Available",
+ },
+ },
+ },
+ },
+ }
+
+ got, err := parseResources(resourceTypesLists)
+ assert.NoError(t, err)
+ assert.Equal(t, 1, len(got))
+
+ // Check resource metadata
+ assert.Equal(t, "test-pod", got[0].GetName())
+ assert.Equal(t, "default", got[0].GetNamespace())
+
+ // Check status and condition
+ statusMap, found, _ := unstructured.NestedMap(got[0].Object, "status")
+ assert.True(t, found)
+ assert.Equal(t, "Available", statusMap["phase"])
+
+ healthSummary, found, _ := unstructured.NestedMap(statusMap, "healthSummary")
+ assert.True(t, found)
+ assert.Equal(t, "Healthy", healthSummary["status"])
+ assert.Equal(t, "Available", healthSummary["reason"])
+ })
+
+ t.Run("successfully parse multiple resources", func(t *testing.T) {
+ resourceTypesLists := map[string][]runtime.Object{
+ "v1/Pod(related)": {
+ &unstructured.Unstructured{
+ Object: map[string]any{
+ "apiVersion": "v1",
+ "kind": "Pod",
+ "metadata": map[string]any{
+ "name": "test-pod-1",
+ "namespace": "default",
+ },
+ "status": map[string]any{
+ "phase": "Pending",
+ },
+ },
+ },
+ &unstructured.Unstructured{
+ Object: map[string]any{
+ "apiVersion": "v1",
+ "kind": "Pod",
+ "metadata": map[string]any{
+ "name": "test-pod-2",
+ "namespace": "default",
+ },
+ "status": map[string]any{
+ "phase": "Error",
+ },
+ },
+ },
+ },
+ }
+
+ got, err := parseResources(resourceTypesLists)
+ assert.NoError(t, err)
+ assert.Equal(t, 2, len(got))
+
+ // Check first resource
+ assert.Equal(t, "test-pod-1", got[0].GetName())
+ statusMap1, found, _ := unstructured.NestedMap(got[0].Object, "status")
+ assert.True(t, found)
+ assert.Equal(t, "Pending", statusMap1["phase"])
+
+ healthSummary1, found, _ := unstructured.NestedMap(statusMap1, "healthSummary")
+ assert.True(t, found)
+ assert.Equal(t, Progressing, healthSummary1["status"])
+ assert.Equal(t, "Pending", healthSummary1["reason"])
+
+ // Check second resource
+ assert.Equal(t, "test-pod-2", got[1].GetName())
+ statusMap2, found, _ := unstructured.NestedMap(got[1].Object, "status")
+ assert.True(t, found)
+ healthSummary2, found, _ := unstructured.NestedMap(statusMap2, "healthSummary")
+ assert.True(t, found)
+ assert.Equal(t, Unhealthy, healthSummary2["status"])
+ assert.Equal(t, "Error", healthSummary2["reason"])
+ })
+}
+
+func TestEnhanceStatus(t *testing.T) {
+ t.Run("healthy running pod", func(t *testing.T) {
+ // Create a ResourceStatus object
+ status := &ResourceStatus{
+ Phase: "Failed",
+ }
+
+ conditions := []metav1.Condition{}
+
+ result := summarizeStatus(status, conditions, "test-pod", "default", nil)
+
+ assert.Equal(t, Unhealthy, result.HealthSummary.Status)
+ assert.Equal(t, "Failed", result.HealthSummary.Reason)
+ })
+
+ t.Run("unhealthy pod with error", func(t *testing.T) {
+ // Create a ResourceStatus object
+ status := &ResourceStatus{
+ Phase: "Error",
+ }
+
+ conditions := []metav1.Condition{
+ {
+ Type: "DisruptionAllowed",
+ Status: metav1.ConditionFalse,
+ Reason: "InsufficientPods",
+ },
+ }
+
+ result := summarizeStatus(status, conditions, "test-pod", "default", nil)
+
+ assert.Equal(t, Unhealthy, result.HealthSummary.Status)
+ assert.Equal(t, "Error", result.HealthSummary.Reason)
+ })
+}
diff --git a/pkg/libhelm/sdk/values.go b/pkg/libhelm/sdk/values.go
index 37ad46775..8818f998f 100644
--- a/pkg/libhelm/sdk/values.go
+++ b/pkg/libhelm/sdk/values.go
@@ -4,7 +4,11 @@ import (
"os"
"github.com/pkg/errors"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/release"
"github.com/rs/zerolog/log"
+ "gopkg.in/yaml.v2"
+ "helm.sh/helm/v3/pkg/action"
)
// GetHelmValuesFromFile reads the values file and parses it into a map[string]any
@@ -40,3 +44,75 @@ func (hspm *HelmSDKPackageManager) GetHelmValuesFromFile(valuesFile string) (map
return vals, nil
}
+
+func (hspm *HelmSDKPackageManager) getValues(getOpts options.GetOptions) (release.Values, error) {
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("namespace", getOpts.Namespace).
+ Str("name", getOpts.Name).
+ Msg("Getting values")
+
+ actionConfig := new(action.Configuration)
+ err := hspm.initActionConfig(actionConfig, getOpts.Namespace, getOpts.KubernetesClusterAccess)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("namespace", getOpts.Namespace).
+ Err(err).Msg("Failed to initialise helm configuration")
+ return release.Values{}, err
+ }
+
+ // Create client for user supplied values
+ userValuesClient := action.NewGetValues(actionConfig)
+ userSuppliedValues, err := userValuesClient.Run(getOpts.Name)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("namespace", getOpts.Namespace).
+ Err(err).Msg("Failed to get user supplied values")
+ return release.Values{}, err
+ }
+
+ // Create separate client for computed values
+ computedValuesClient := action.NewGetValues(actionConfig)
+ computedValuesClient.AllValues = true
+ computedValues, err := computedValuesClient.Run(getOpts.Name)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("namespace", getOpts.Namespace).
+ Err(err).Msg("Failed to get computed values")
+ return release.Values{}, err
+ }
+
+ userSuppliedValuesByte, err := yaml.Marshal(userSuppliedValues)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).Msg("Failed to marshal user supplied values")
+ return release.Values{}, err
+ }
+
+ computedValuesByte, err := yaml.Marshal(computedValues)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).Msg("Failed to marshal computed values")
+ return release.Values{}, err
+ }
+
+ // Handle the case where the values are an empty object
+ userSuppliedValuesString := string(userSuppliedValuesByte)
+ if userSuppliedValuesString == "{}\n" {
+ userSuppliedValuesString = ""
+ }
+ computedValuesString := string(computedValuesByte)
+ if computedValuesString == "{}\n" {
+ computedValuesString = ""
+ }
+
+ return release.Values{
+ UserSuppliedValues: userSuppliedValuesString,
+ ComputedValues: computedValuesString,
+ }, nil
+}
diff --git a/pkg/libhelm/test/mock.go b/pkg/libhelm/test/mock.go
index 58d53715c..518b53fbd 100644
--- a/pkg/libhelm/test/mock.go
+++ b/pkg/libhelm/test/mock.go
@@ -1,6 +1,7 @@
package test
import (
+ "slices"
"strings"
"github.com/portainer/portainer/pkg/libhelm/options"
@@ -91,29 +92,11 @@ func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
return nil, nil
}
-// Get release details - all, hooks, manifest, notes and values
-func (hpm *helmMockPackageManager) Get(getOpts options.GetOptions) ([]byte, error) {
- switch getOpts.ReleaseResource {
- case options.GetAll:
- return []byte(strings.Join([]string{MockReleaseHooks, MockReleaseManifest, MockReleaseNotes, MockReleaseValues}, "---\n")), nil
- case options.GetHooks:
- return []byte(MockReleaseHooks), nil
- case options.GetManifest:
- return []byte(MockReleaseManifest), nil
- case options.GetNotes:
- return []byte(MockReleaseNotes), nil
- case options.GetValues:
- return []byte(MockReleaseValues), nil
- default:
- return nil, errors.New("invalid release resource")
- }
-}
-
// Uninstall a helm chart (not thread safe)
func (hpm *helmMockPackageManager) Uninstall(uninstallOpts options.UninstallOptions) error {
for i, rel := range mockCharts {
if rel.Name == uninstallOpts.Name && rel.Namespace == uninstallOpts.Namespace {
- mockCharts = append(mockCharts[:i], mockCharts[i+1:]...)
+ mockCharts = slices.Delete(mockCharts, i, i+1)
}
}
return nil
@@ -124,6 +107,25 @@ func (hpm *helmMockPackageManager) List(listOpts options.ListOptions) ([]release
return mockCharts, nil
}
+// Get a helm release (not thread safe)
+func (hpm *helmMockPackageManager) Get(getOpts options.GetOptions) (*release.Release, error) {
+ index := slices.IndexFunc(mockCharts, func(re release.ReleaseElement) bool {
+ return re.Name == getOpts.Name && re.Namespace == getOpts.Namespace
+ })
+ return newMockRelease(&mockCharts[index]), nil
+}
+
+func (hpm *helmMockPackageManager) GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error) {
+ var result []*release.Release
+ for i, v := range mockCharts {
+ if v.Name == historyOpts.Name && v.Namespace == historyOpts.Namespace {
+ result = append(result, newMockRelease(&mockCharts[i]))
+ }
+ }
+
+ return result, nil
+}
+
const mockPortainerIndex = `apiVersion: v1
entries:
portainer:
diff --git a/pkg/libhelm/types/types.go b/pkg/libhelm/types/types.go
index 8c0caa80b..7c78cbe88 100644
--- a/pkg/libhelm/types/types.go
+++ b/pkg/libhelm/types/types.go
@@ -14,6 +14,8 @@ type HelmPackageManager interface {
List(listOpts options.ListOptions) ([]release.ReleaseElement, error)
Upgrade(upgradeOpts options.InstallOptions) (*release.Release, error)
Uninstall(uninstallOpts options.UninstallOptions) error
+ Get(getOpts options.GetOptions) (*release.Release, error)
+ GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error)
}
type Repository interface {
diff --git a/pkg/libkubectl/client.go b/pkg/libkubectl/client.go
new file mode 100644
index 000000000..bfb86f172
--- /dev/null
+++ b/pkg/libkubectl/client.go
@@ -0,0 +1,63 @@
+package libkubectl
+
+import (
+ "bytes"
+ "errors"
+
+ "k8s.io/cli-runtime/pkg/genericclioptions"
+ "k8s.io/cli-runtime/pkg/genericiooptions"
+ "k8s.io/kubectl/pkg/cmd/util"
+)
+
+type ClientAccess struct {
+ Token string
+ ServerUrl string
+}
+
+type Client struct {
+ factory util.Factory
+ streams genericclioptions.IOStreams
+ out *bytes.Buffer
+}
+
+// NewClient creates a new kubectl client
+func NewClient(libKubectlAccess *ClientAccess, namespace, kubeconfig string, insecure bool) (*Client, error) {
+ configFlags, err := generateConfigFlags(libKubectlAccess.Token, libKubectlAccess.ServerUrl, namespace, kubeconfig, insecure)
+ if err != nil {
+ return nil, err
+ }
+
+ streams, _, out, _ := genericiooptions.NewTestIOStreams()
+
+ return &Client{
+ factory: util.NewFactory(configFlags),
+ streams: streams,
+ out: out,
+ }, nil
+}
+
+// generateConfigFlags generates the config flags for the kubectl client
+// If kubeconfigPath is provided, it will be used instead of server and token
+// If server and token are provided, they will be used to connect to the cluster
+// If neither kubeconfigPath or server and token are provided, an error will be returned
+func generateConfigFlags(token, server, namespace, kubeconfigPath string, insecure bool) (*genericclioptions.ConfigFlags, error) {
+ if kubeconfigPath == "" && (server == "" || token == "") {
+ return nil, errors.New("must provide either a kubeconfig path or a server and token")
+ }
+
+ configFlags := genericclioptions.NewConfigFlags(true)
+ if namespace != "" {
+ configFlags.Namespace = &namespace
+ }
+
+ if kubeconfigPath != "" {
+ configFlags.KubeConfig = &kubeconfigPath
+ } else {
+ configFlags.APIServer = &server
+ configFlags.BearerToken = &token
+ }
+
+ configFlags.Insecure = &insecure
+
+ return configFlags, nil
+}
diff --git a/pkg/libkubectl/client_test.go b/pkg/libkubectl/client_test.go
new file mode 100644
index 000000000..c2c7fd739
--- /dev/null
+++ b/pkg/libkubectl/client_test.go
@@ -0,0 +1,101 @@
+package libkubectl
+
+import (
+ "testing"
+)
+
+func TestNewClient(t *testing.T) {
+ tests := []struct {
+ name string
+ libKubectlAccess ClientAccess
+ namespace string
+ kubeconfig string
+ insecure bool
+ wantErr bool
+ errContains string
+ }{
+ {
+ name: "valid client with token and server",
+ libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
+ namespace: "default",
+ insecure: true,
+ wantErr: false,
+ },
+ {
+ name: "valid client with kubeconfig",
+ kubeconfig: "/path/to/kubeconfig",
+ namespace: "test-namespace",
+ insecure: false,
+ wantErr: false,
+ },
+ {
+ name: "missing both token/server and kubeconfig",
+ namespace: "default",
+ insecure: false,
+ wantErr: true,
+ errContains: "must provide either a kubeconfig path or a server and token",
+ },
+ {
+ name: "missing token with server",
+ libKubectlAccess: ClientAccess{ServerUrl: "https://localhost:6443"},
+ namespace: "default",
+ insecure: false,
+ wantErr: true,
+ errContains: "must provide either a kubeconfig path or a server and token",
+ },
+ {
+ name: "missing server with token",
+ libKubectlAccess: ClientAccess{Token: "test-token"},
+ namespace: "default",
+ insecure: false,
+ wantErr: true,
+ errContains: "must provide either a kubeconfig path or a server and token",
+ },
+ {
+ name: "empty namespace is valid",
+ libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
+ namespace: "",
+ insecure: false,
+ wantErr: false,
+ },
+ {
+ name: "insecure true with valid credentials",
+ libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
+ namespace: "default",
+ insecure: true,
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client, err := NewClient(&tt.libKubectlAccess, tt.namespace, tt.kubeconfig, tt.insecure)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ if err != nil && tt.errContains != "" {
+ if got := err.Error(); got != tt.errContains {
+ t.Errorf("NewClient() error = %v, want error containing %v", got, tt.errContains)
+ }
+ return
+ }
+
+ if !tt.wantErr {
+ if client == nil {
+ t.Error("NewClient() returned nil client when no error was expected")
+ return
+ }
+
+ // Verify client fields are properly initialized
+ if client.factory == nil {
+ t.Error("NewClient() client.factory is nil")
+ }
+ if client.out == nil {
+ t.Error("NewClient() client.out is nil")
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/libkubectl/describe.go b/pkg/libkubectl/describe.go
new file mode 100644
index 000000000..19588f606
--- /dev/null
+++ b/pkg/libkubectl/describe.go
@@ -0,0 +1,40 @@
+package libkubectl
+
+import (
+ "fmt"
+
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/cli-runtime/pkg/resource"
+ describecmd "k8s.io/kubectl/pkg/cmd/describe"
+ cmdutil "k8s.io/kubectl/pkg/cmd/util"
+ "k8s.io/kubectl/pkg/describe"
+)
+
+// Describe returns the description of a resource
+// name is the name of the resource, kind is the kind of the resource, and namespace is the namespace of the resource
+// this is identical to running `kubectl describe --namespace `
+func (c *Client) Describe(namespace, name, kind string) (string, error) {
+ describeOptions := &describecmd.DescribeOptions{
+ BuilderArgs: []string{kind, name},
+ Describer: func(mapping *meta.RESTMapping) (describe.ResourceDescriber, error) {
+ return describe.DescriberFn(c.factory, mapping)
+ },
+ FilenameOptions: &resource.FilenameOptions{},
+ DescriberSettings: &describe.DescriberSettings{
+ ShowEvents: true,
+ ChunkSize: cmdutil.DefaultChunkSize,
+ },
+ IOStreams: c.streams,
+ NewBuilder: c.factory.NewBuilder,
+ }
+
+ if namespace != "" {
+ describeOptions.Namespace = namespace
+ }
+
+ if err := describeOptions.Run(); err != nil {
+ return "", fmt.Errorf("error describing resources: %w", err)
+ }
+
+ return c.out.String(), nil
+}
diff --git a/yarn.lock b/yarn.lock
index a041f9578..4f1b90b95 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -13148,6 +13148,11 @@ markdown-to-jsx@^7.1.8:
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.2.0.tgz#e7b46b65955f6a04d48a753acd55874a14bdda4b"
integrity sha512-3l4/Bigjm4bEqjCR6Xr+d4DtM1X6vvtGsMGSjJYyep8RjjIvcWtrXBS8Wbfe1/P+atKNMccpsraESIaWVplzVg==
+markdown-to-jsx@^7.7.4:
+ version "7.7.4"
+ resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.7.4.tgz#507d17c15af72ddf970fca84a95f0243244fcfa9"
+ integrity sha512-1bSfXyBKi+EYS3YY+e0Csuxf8oZ3decdfhOav/Z7Wrk89tjudyL5FOmwZQUoy0/qVXGUl+6Q3s2SWtpDEWITfQ==
+
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
From 823f2a7991223ea57f2df4d763c8e2547faddb62 Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Fri, 11 Apr 2025 08:26:11 +1200
Subject: [PATCH 084/212] fix(edge): missing env var in async agent docker
snapshot [BE-11709] (#625)
---
pkg/snapshot/docker.go | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/pkg/snapshot/docker.go b/pkg/snapshot/docker.go
index 49ae57c59..3ea4dc0d5 100644
--- a/pkg/snapshot/docker.go
+++ b/pkg/snapshot/docker.go
@@ -136,6 +136,7 @@ func dockerSnapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Cl
stacks := make(map[string]struct{})
gpuUseSet := make(map[string]struct{})
gpuUseAll := false
+ containerEnvs := make(map[string][]string)
for _, container := range containers {
for k, v := range container.Labels {
@@ -148,7 +149,7 @@ func dockerSnapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Cl
continue
}
- // Snapshot GPUs
+ // Snapshot GPUs and Env
response, err := cli.ContainerInspect(context.Background(), container.ID)
if err != nil && !snapshot.Swarm {
return err
@@ -167,6 +168,8 @@ func dockerSnapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Cl
continue
}
+ containerEnvs[container.ID] = response.Config.Env
+
var gpuOptions *_container.DeviceRequest
for _, deviceRequest := range response.HostConfig.Resources.DeviceRequests {
@@ -206,7 +209,7 @@ func dockerSnapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Cl
snapshot.StackCount += len(stacks)
for _, container := range containers {
- snapshot.SnapshotRaw.Containers = append(snapshot.SnapshotRaw.Containers, portainer.DockerContainerSnapshot{Container: container})
+ snapshot.SnapshotRaw.Containers = append(snapshot.SnapshotRaw.Containers, portainer.DockerContainerSnapshot{Container: container, Env: containerEnvs[container.ID]})
}
return nil
From d68fe429180182f07bb6ac9fe32fe6491764ecc5 Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Fri, 11 Apr 2025 08:39:39 +1200
Subject: [PATCH 085/212] fix(apps): better align sub tables [r8s-255] (#617)
---
.../ListView/ApplicationsDatatable/PublishedPorts.tsx | 2 +-
.../applications/ListView/ApplicationsDatatable/SubRow.tsx | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/PublishedPorts.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/PublishedPorts.tsx
index 636504978..9bdb47f06 100644
--- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/PublishedPorts.tsx
+++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/PublishedPorts.tsx
@@ -14,7 +14,7 @@ export function PublishedPorts({ item }: { item: Application }) {
}
return (
-
+
Published URL(s)
{urlsWithTypes.map(({ url, type }) => (
diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx
index e6c1d8786..ce4b5d4ec 100644
--- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx
+++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx
@@ -19,11 +19,11 @@ export function SubRow({
const {
user: { Username: username },
} = useCurrentUser();
- const colSpan = hideStacks ? 8 : 9;
+ const colSpan = hideStacks ? 7 : 8;
return (
-
+
{item.KubernetesApplications ? (
Date: Thu, 10 Apr 2025 20:12:27 -0300
Subject: [PATCH 086/212] fix(edgestacks): fix edge stack update when using Git
BE-11766 (#629)
---
api/http/handler/edgestacks/edgestack_update.go | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go
index 27a279fb3..a3d59abb8 100644
--- a/api/http/handler/edgestacks/edgestack_update.go
+++ b/api/http/handler/edgestacks/edgestack_update.go
@@ -145,11 +145,15 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
if len(relatedEnvironmentsToRemove) > 0 {
- tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID)
+ if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID); err != nil {
+ return nil, nil, errors.WithMessage(err, "Unable to remove edge stack relations from the database")
+ }
}
if len(relatedEnvironmentsToAdd) > 0 {
- tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID)
+ if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID); err != nil {
+ return nil, nil, errors.WithMessage(err, "Unable to add edge stack relations to the database")
+ }
}
return newRelatedEnvironmentIDs, relatedEnvironmentsToAdd, nil
From ebc25e45d3c4271c582764446a77bb0d5f51e0e5 Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Sat, 12 Apr 2025 10:24:23 +1200
Subject: [PATCH 087/212] fix(edge): redeploy edge stack doesn't apply to std
agents [BE-11766] (#633)
---
api/dataservices/endpointrelation/tx.go | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/api/dataservices/endpointrelation/tx.go b/api/dataservices/endpointrelation/tx.go
index 2b2d90280..bc58678fd 100644
--- a/api/dataservices/endpointrelation/tx.go
+++ b/api/dataservices/endpointrelation/tx.go
@@ -93,6 +93,10 @@ func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portaine
}
}
+ service.service.mu.Lock()
+ service.service.endpointRelationsCache = nil
+ service.service.mu.Unlock()
+
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
edgeStack.NumDeployments += len(endpointIDs)
}); err != nil {
@@ -119,6 +123,10 @@ func (service ServiceTx) RemoveEndpointRelationsForEdgeStack(endpointIDs []porta
}
}
+ service.service.mu.Lock()
+ service.service.endpointRelationsCache = nil
+ service.service.mu.Unlock()
+
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
edgeStack.NumDeployments -= len(endpointIDs)
}); err != nil {
From c331ada086e08576db68bfe2355fb9b56108c205 Mon Sep 17 00:00:00 2001
From: LP B
Date: Sun, 13 Apr 2025 23:05:48 +0200
Subject: [PATCH 088/212] feat(app): 1s staleTime to avoid sending repeated
requests (#607)
---
app/portainer/tags/queries.ts | 1 -
app/portainer/users/queries/useLoadCurrentUser.ts | 3 +--
app/portainer/users/queries/useUser.ts | 6 +-----
app/react-tools/react-query.ts | 1 +
.../HelmRepositoryDatatable/helm-repositories.service.ts | 1 -
.../portainer/environments/environment-groups/queries.ts | 1 -
app/react/portainer/environments/queries/useEnvironment.ts | 1 -
.../registries/repositories/queries/useTagDetails.ts | 2 +-
.../portainer/settings/queries/useExperimentalSettings.ts | 1 -
app/react/portainer/settings/queries/useSettings.ts | 1 -
10 files changed, 4 insertions(+), 14 deletions(-)
diff --git a/app/portainer/tags/queries.ts b/app/portainer/tags/queries.ts
index fe4bac346..381de44b1 100644
--- a/app/portainer/tags/queries.ts
+++ b/app/portainer/tags/queries.ts
@@ -20,7 +20,6 @@ export function useTags({
enabled = true,
}: { select?: (tags: Tag[]) => T; enabled?: boolean } = {}) {
return useQuery(tagKeys.all, () => getTags(), {
- staleTime: 50,
select,
enabled,
...withError('Failed to retrieve tags'),
diff --git a/app/portainer/users/queries/useLoadCurrentUser.ts b/app/portainer/users/queries/useLoadCurrentUser.ts
index bd47fbee9..324f27110 100644
--- a/app/portainer/users/queries/useLoadCurrentUser.ts
+++ b/app/portainer/users/queries/useLoadCurrentUser.ts
@@ -12,10 +12,9 @@ interface CurrentUserResponse extends User {
forceChangePassword: boolean;
}
-export function useLoadCurrentUser({ staleTime }: { staleTime?: number } = {}) {
+export function useLoadCurrentUser() {
return useQuery(userQueryKeys.me(), () => getCurrentUser(), {
...withError('Unable to retrieve user details'),
- staleTime,
});
}
diff --git a/app/portainer/users/queries/useUser.ts b/app/portainer/users/queries/useUser.ts
index 01e069829..640e22374 100644
--- a/app/portainer/users/queries/useUser.ts
+++ b/app/portainer/users/queries/useUser.ts
@@ -8,13 +8,9 @@ import { User, UserId } from '../types';
import { userQueryKeys } from './queryKeys';
-export function useUser(
- id: UserId,
- { staleTime }: { staleTime?: number } = {}
-) {
+export function useUser(id: UserId) {
return useQuery(userQueryKeys.user(id), () => getUser(id), {
...withError('Unable to retrieve user details'),
- staleTime,
});
}
diff --git a/app/react-tools/react-query.ts b/app/react-tools/react-query.ts
index 336b72308..2ebedbd18 100644
--- a/app/react-tools/react-query.ts
+++ b/app/react-tools/react-query.ts
@@ -88,6 +88,7 @@ export function createQueryClient() {
defaultOptions: {
queries: {
networkMode: 'offlineFirst',
+ staleTime: 1000, // 1s stale time by default
},
},
mutationCache: new MutationCache({
diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts
index c12ed9ffa..b4c5cc63b 100644
--- a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts
+++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts
@@ -83,7 +83,6 @@ export function useDeleteHelmRepositoriesMutation() {
export function useHelmRepositories(userId: number) {
return useQuery(['helmrepositories'], () => getHelmRepositories(userId), {
- staleTime: 20,
...withError('Unable to retrieve Helm repositories'),
});
}
diff --git a/app/react/portainer/environments/environment-groups/queries.ts b/app/react/portainer/environments/environment-groups/queries.ts
index 74a588d24..9c93dde62 100644
--- a/app/react/portainer/environments/environment-groups/queries.ts
+++ b/app/react/portainer/environments/environment-groups/queries.ts
@@ -31,7 +31,6 @@ export function useGroup(
return getGroup(groupId);
},
{
- staleTime: 50,
select,
enabled: groupId !== undefined,
...withGlobalError('Failed loading group'),
diff --git a/app/react/portainer/environments/queries/useEnvironment.ts b/app/react/portainer/environments/queries/useEnvironment.ts
index b2b8fd6bd..1843d00e6 100644
--- a/app/react/portainer/environments/queries/useEnvironment.ts
+++ b/app/react/portainer/environments/queries/useEnvironment.ts
@@ -27,7 +27,6 @@ export function useEnvironment(
{
select,
...withError('Failed loading environment'),
- staleTime: 50,
enabled: !!environmentId,
refetchInterval() {
return options?.autoRefreshRate ?? false;
diff --git a/app/react/portainer/registries/repositories/queries/useTagDetails.ts b/app/react/portainer/registries/repositories/queries/useTagDetails.ts
index 89da912f7..6997a8eca 100644
--- a/app/react/portainer/registries/repositories/queries/useTagDetails.ts
+++ b/app/react/portainer/registries/repositories/queries/useTagDetails.ts
@@ -20,7 +20,7 @@ interface Params {
export function useTagDetails(
params: Params,
{
- staleTime = 0,
+ staleTime,
select,
}: { select?: (model: RepositoryTagViewModel) => T; staleTime?: number } = {}
) {
diff --git a/app/react/portainer/settings/queries/useExperimentalSettings.ts b/app/react/portainer/settings/queries/useExperimentalSettings.ts
index dda159ca1..f67e95152 100644
--- a/app/react/portainer/settings/queries/useExperimentalSettings.ts
+++ b/app/react/portainer/settings/queries/useExperimentalSettings.ts
@@ -19,7 +19,6 @@ export function useExperimentalSettings(
return useQuery(queryKeys.experimental(), getExperimentalSettings, {
select,
enabled,
- staleTime: 50,
...withError('Unable to retrieve experimental settings'),
});
}
diff --git a/app/react/portainer/settings/queries/useSettings.ts b/app/react/portainer/settings/queries/useSettings.ts
index 04d7094c8..15a206e9a 100644
--- a/app/react/portainer/settings/queries/useSettings.ts
+++ b/app/react/portainer/settings/queries/useSettings.ts
@@ -22,7 +22,6 @@ export function useSettings(
return useQuery(queryKeys.base(), getSettings, {
select,
enabled,
- staleTime: 50,
...withError('Unable to retrieve settings'),
});
}
From 7aa9f8b1c3f366df3d7c09f787d6cb7574bc2255 Mon Sep 17 00:00:00 2001
From: LP B
Date: Mon, 14 Apr 2025 01:12:11 +0200
Subject: [PATCH 089/212] Revert "feat(app): 1s staleTime to avoid sending
repeated requests" (#639)
---
app/portainer/tags/queries.ts | 1 +
app/portainer/users/queries/useLoadCurrentUser.ts | 3 ++-
app/portainer/users/queries/useUser.ts | 6 +++++-
app/react-tools/react-query.ts | 1 -
.../HelmRepositoryDatatable/helm-repositories.service.ts | 1 +
.../portainer/environments/environment-groups/queries.ts | 1 +
app/react/portainer/environments/queries/useEnvironment.ts | 1 +
.../registries/repositories/queries/useTagDetails.ts | 2 +-
.../portainer/settings/queries/useExperimentalSettings.ts | 1 +
app/react/portainer/settings/queries/useSettings.ts | 1 +
10 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/app/portainer/tags/queries.ts b/app/portainer/tags/queries.ts
index 381de44b1..fe4bac346 100644
--- a/app/portainer/tags/queries.ts
+++ b/app/portainer/tags/queries.ts
@@ -20,6 +20,7 @@ export function useTags({
enabled = true,
}: { select?: (tags: Tag[]) => T; enabled?: boolean } = {}) {
return useQuery(tagKeys.all, () => getTags(), {
+ staleTime: 50,
select,
enabled,
...withError('Failed to retrieve tags'),
diff --git a/app/portainer/users/queries/useLoadCurrentUser.ts b/app/portainer/users/queries/useLoadCurrentUser.ts
index 324f27110..bd47fbee9 100644
--- a/app/portainer/users/queries/useLoadCurrentUser.ts
+++ b/app/portainer/users/queries/useLoadCurrentUser.ts
@@ -12,9 +12,10 @@ interface CurrentUserResponse extends User {
forceChangePassword: boolean;
}
-export function useLoadCurrentUser() {
+export function useLoadCurrentUser({ staleTime }: { staleTime?: number } = {}) {
return useQuery(userQueryKeys.me(), () => getCurrentUser(), {
...withError('Unable to retrieve user details'),
+ staleTime,
});
}
diff --git a/app/portainer/users/queries/useUser.ts b/app/portainer/users/queries/useUser.ts
index 640e22374..01e069829 100644
--- a/app/portainer/users/queries/useUser.ts
+++ b/app/portainer/users/queries/useUser.ts
@@ -8,9 +8,13 @@ import { User, UserId } from '../types';
import { userQueryKeys } from './queryKeys';
-export function useUser(id: UserId) {
+export function useUser(
+ id: UserId,
+ { staleTime }: { staleTime?: number } = {}
+) {
return useQuery(userQueryKeys.user(id), () => getUser(id), {
...withError('Unable to retrieve user details'),
+ staleTime,
});
}
diff --git a/app/react-tools/react-query.ts b/app/react-tools/react-query.ts
index 2ebedbd18..336b72308 100644
--- a/app/react-tools/react-query.ts
+++ b/app/react-tools/react-query.ts
@@ -88,7 +88,6 @@ export function createQueryClient() {
defaultOptions: {
queries: {
networkMode: 'offlineFirst',
- staleTime: 1000, // 1s stale time by default
},
},
mutationCache: new MutationCache({
diff --git a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts
index b4c5cc63b..c12ed9ffa 100644
--- a/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts
+++ b/app/react/portainer/account/AccountView/HelmRepositoryDatatable/helm-repositories.service.ts
@@ -83,6 +83,7 @@ export function useDeleteHelmRepositoriesMutation() {
export function useHelmRepositories(userId: number) {
return useQuery(['helmrepositories'], () => getHelmRepositories(userId), {
+ staleTime: 20,
...withError('Unable to retrieve Helm repositories'),
});
}
diff --git a/app/react/portainer/environments/environment-groups/queries.ts b/app/react/portainer/environments/environment-groups/queries.ts
index 9c93dde62..74a588d24 100644
--- a/app/react/portainer/environments/environment-groups/queries.ts
+++ b/app/react/portainer/environments/environment-groups/queries.ts
@@ -31,6 +31,7 @@ export function useGroup(
return getGroup(groupId);
},
{
+ staleTime: 50,
select,
enabled: groupId !== undefined,
...withGlobalError('Failed loading group'),
diff --git a/app/react/portainer/environments/queries/useEnvironment.ts b/app/react/portainer/environments/queries/useEnvironment.ts
index 1843d00e6..b2b8fd6bd 100644
--- a/app/react/portainer/environments/queries/useEnvironment.ts
+++ b/app/react/portainer/environments/queries/useEnvironment.ts
@@ -27,6 +27,7 @@ export function useEnvironment(
{
select,
...withError('Failed loading environment'),
+ staleTime: 50,
enabled: !!environmentId,
refetchInterval() {
return options?.autoRefreshRate ?? false;
diff --git a/app/react/portainer/registries/repositories/queries/useTagDetails.ts b/app/react/portainer/registries/repositories/queries/useTagDetails.ts
index 6997a8eca..89da912f7 100644
--- a/app/react/portainer/registries/repositories/queries/useTagDetails.ts
+++ b/app/react/portainer/registries/repositories/queries/useTagDetails.ts
@@ -20,7 +20,7 @@ interface Params {
export function useTagDetails(
params: Params,
{
- staleTime,
+ staleTime = 0,
select,
}: { select?: (model: RepositoryTagViewModel) => T; staleTime?: number } = {}
) {
diff --git a/app/react/portainer/settings/queries/useExperimentalSettings.ts b/app/react/portainer/settings/queries/useExperimentalSettings.ts
index f67e95152..dda159ca1 100644
--- a/app/react/portainer/settings/queries/useExperimentalSettings.ts
+++ b/app/react/portainer/settings/queries/useExperimentalSettings.ts
@@ -19,6 +19,7 @@ export function useExperimentalSettings(
return useQuery(queryKeys.experimental(), getExperimentalSettings, {
select,
enabled,
+ staleTime: 50,
...withError('Unable to retrieve experimental settings'),
});
}
diff --git a/app/react/portainer/settings/queries/useSettings.ts b/app/react/portainer/settings/queries/useSettings.ts
index 15a206e9a..04d7094c8 100644
--- a/app/react/portainer/settings/queries/useSettings.ts
+++ b/app/react/portainer/settings/queries/useSettings.ts
@@ -22,6 +22,7 @@ export function useSettings(
return useQuery(queryKeys.base(), getSettings, {
select,
enabled,
+ staleTime: 50,
...withError('Unable to retrieve settings'),
});
}
From 2c37f32fa6b707140144b6611e4708d2131532c7 Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Mon, 14 Apr 2025 13:13:38 +1200
Subject: [PATCH 090/212] version: bump version to 2.29.0 (#637)
---
api/datastore/test_data/output_24_to_latest.json | 4 ++--
api/http/handler/handler.go | 2 +-
api/portainer.go | 2 +-
package.json | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index dcbf37413..21f3f8630 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -610,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
- "KubectlShellImage": "portainer/kubectl-shell:2.28.0",
+ "KubectlShellImage": "portainer/kubectl-shell:2.29.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
- "VERSION": "{\"SchemaVersion\":\"2.28.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
+ "VERSION": "{\"SchemaVersion\":\"2.29.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
\ No newline at end of file
diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go
index d9ac7fbc6..42c555905 100644
--- a/api/http/handler/handler.go
+++ b/api/http/handler/handler.go
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
-// @version 2.28.0
+// @version 2.29.0
// @description.markdown api-description.md
// @termsOfService
diff --git a/api/portainer.go b/api/portainer.go
index 0c8b92b08..1db7142d5 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -1638,7 +1638,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
- APIVersion = "2.28.0"
+ APIVersion = "2.29.0"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
diff --git a/package.json b/package.json
index e0cc82463..ff81a1bb0 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
- "version": "2.28.0",
+ "version": "2.29.0",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"
From 730c1115ceecfa28c786ef032ebc55629dad37f6 Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Mon, 14 Apr 2025 17:46:40 -0300
Subject: [PATCH 091/212] fix(proxy): remove code duplication BE-11627 (#644)
---
api/http/proxy/factory/agent.go | 5 ++---
api/http/proxy/factory/azure.go | 2 +-
api/http/proxy/factory/docker.go | 2 +-
api/http/proxy/factory/gitlab.go | 2 +-
api/http/proxy/factory/kubernetes.go | 6 +++---
api/http/proxy/factory/reverse_proxy.go | 2 +-
6 files changed, 9 insertions(+), 10 deletions(-)
diff --git a/api/http/proxy/factory/agent.go b/api/http/proxy/factory/agent.go
index bd08efd6c..60323a195 100644
--- a/api/http/proxy/factory/agent.go
+++ b/api/http/proxy/factory/agent.go
@@ -52,7 +52,7 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
endpointURL.Scheme = "https"
}
- proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
+ proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = agent.NewTransport(factory.signatureService, httpTransport)
@@ -63,8 +63,7 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
Port: 0,
}
- err = proxyServer.start()
- if err != nil {
+ if err := proxyServer.start(); err != nil {
return nil, errors.Wrap(err, "failed starting proxy server")
}
diff --git a/api/http/proxy/factory/azure.go b/api/http/proxy/factory/azure.go
index eba2fbd6a..e210dec2a 100644
--- a/api/http/proxy/factory/azure.go
+++ b/api/http/proxy/factory/azure.go
@@ -15,7 +15,7 @@ func newAzureProxy(endpoint *portainer.Endpoint, dataStore dataservices.DataStor
return nil, err
}
- proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
+ proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials, dataStore, endpoint)
return proxy, nil
}
diff --git a/api/http/proxy/factory/docker.go b/api/http/proxy/factory/docker.go
index e558bebb9..31d183c9b 100644
--- a/api/http/proxy/factory/docker.go
+++ b/api/http/proxy/factory/docker.go
@@ -72,7 +72,7 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
return nil, err
}
- proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
+ proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = dockerTransport
return proxy, nil
}
diff --git a/api/http/proxy/factory/gitlab.go b/api/http/proxy/factory/gitlab.go
index cbe28411c..7c1ae96e3 100644
--- a/api/http/proxy/factory/gitlab.go
+++ b/api/http/proxy/factory/gitlab.go
@@ -13,7 +13,7 @@ func newGitlabProxy(uri string) (http.Handler, error) {
return nil, err
}
- proxy := newSingleHostReverseProxyWithHostHeader(url)
+ proxy := NewSingleHostReverseProxyWithHostHeader(url)
proxy.Transport = gitlab.NewTransport()
return proxy, nil
}
diff --git a/api/http/proxy/factory/kubernetes.go b/api/http/proxy/factory/kubernetes.go
index 0a74fa3f2..eceee181a 100644
--- a/api/http/proxy/factory/kubernetes.go
+++ b/api/http/proxy/factory/kubernetes.go
@@ -43,7 +43,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin
return nil, err
}
- proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
+ proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = transport
return proxy, nil
@@ -73,7 +73,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp
}
endpointURL.Scheme = "http"
- proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
+ proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory)
return proxy, nil
@@ -104,7 +104,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En
return nil, err
}
- proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
+ proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore)
return proxy, nil
diff --git a/api/http/proxy/factory/reverse_proxy.go b/api/http/proxy/factory/reverse_proxy.go
index 93d22f94d..fdee70c4d 100644
--- a/api/http/proxy/factory/reverse_proxy.go
+++ b/api/http/proxy/factory/reverse_proxy.go
@@ -10,7 +10,7 @@ import (
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
-func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
+func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
targetQuery := target.RawQuery
director := func(req *http.Request) {
req.URL.Scheme = target.Scheme
From 7de037029fea59150f0871f160eec92efeb56bec Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Tue, 15 Apr 2025 09:58:55 +1200
Subject: [PATCH 092/212] security: cve-2025-30204 and other low ones - develop
[BE-11781] (#638)
---
binary-version.json | 3 +-
go.mod | 36 +++++++++++------------
go.sum | 72 ++++++++++++++++++++++-----------------------
3 files changed, 55 insertions(+), 56 deletions(-)
diff --git a/binary-version.json b/binary-version.json
index 8b37fa345..3f190e5d3 100644
--- a/binary-version.json
+++ b/binary-version.json
@@ -1,6 +1,5 @@
{
"docker": "v27.5.1",
- "helm": "v3.17.2",
"kubectl": "v1.32.2",
- "mingit": "2.48.1.1"
+ "mingit": "2.49.0.1"
}
diff --git a/go.mod b/go.mod
index ad6c86799..22ed99fa2 100644
--- a/go.mod
+++ b/go.mod
@@ -25,7 +25,7 @@ require (
github.com/go-ldap/ldap/v3 v3.4.1
github.com/go-playground/validator/v10 v10.12.0
github.com/gofrs/uuid v4.2.0+incompatible
- github.com/golang-jwt/jwt/v4 v4.5.0
+ github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.6.0
github.com/gorilla/csrf v1.7.2
github.com/gorilla/mux v1.8.1
@@ -48,21 +48,21 @@ require (
github.com/urfave/negroni v1.0.0
github.com/viney-shih/go-lock v1.1.1
go.etcd.io/bbolt v1.3.11
- golang.org/x/crypto v0.35.0
+ golang.org/x/crypto v0.36.0
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
golang.org/x/mod v0.21.0
- golang.org/x/oauth2 v0.23.0
- golang.org/x/sync v0.11.0
+ golang.org/x/oauth2 v0.27.0
+ golang.org/x/sync v0.12.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.1
- k8s.io/api v0.32.1
- k8s.io/apimachinery v0.32.1
- k8s.io/cli-runtime v0.32.1
- k8s.io/client-go v0.32.1
- k8s.io/kubectl v0.32.1
- k8s.io/metrics v0.32.1
+ helm.sh/helm/v3 v3.17.3
+ k8s.io/api v0.32.2
+ k8s.io/apimachinery v0.32.2
+ k8s.io/cli-runtime v0.32.2
+ k8s.io/client-go v0.32.2
+ k8s.io/kubectl v0.32.2
+ k8s.io/metrics v0.32.2
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
)
@@ -278,10 +278,10 @@ require (
go.opentelemetry.io/otel/trace v1.28.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/mock v0.5.0 // indirect
- golang.org/x/net v0.33.0 // indirect
- golang.org/x/sys v0.30.0 // indirect
- golang.org/x/term v0.29.0 // indirect
- golang.org/x/text v0.22.0 // indirect
+ golang.org/x/net v0.37.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
+ golang.org/x/term v0.30.0 // indirect
+ golang.org/x/text v0.23.0 // indirect
golang.org/x/time v0.7.0 // indirect
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
@@ -292,9 +292,9 @@ require (
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
- k8s.io/apiextensions-apiserver v0.32.1 // indirect
- k8s.io/apiserver v0.32.1 // indirect
- k8s.io/component-base v0.32.1 // indirect
+ k8s.io/apiextensions-apiserver v0.32.2 // indirect
+ k8s.io/apiserver v0.32.2 // indirect
+ k8s.io/component-base v0.32.2 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
diff --git a/go.sum b/go.sum
index 110e5eb4b..2ca59e4f6 100644
--- a/go.sum
+++ b/go.sum
@@ -311,8 +311,8 @@ github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
-github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
+github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -782,8 +782,8 @@ 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.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
-golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -802,10 +802,10 @@ 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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
-golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
-golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
-golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
+golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
+golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -814,8 +814,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.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
-golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
+golang.org/x/sync v0.12.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=
@@ -842,20 +842,20 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.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.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
-golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
+golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
+golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
-golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -915,30 +915,30 @@ 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.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
-helm.sh/helm/v3 v3.17.1 h1:gzVoAD+qVuoJU6KDMSAeo0xRJ6N1znRxz3wyuXRmJDk=
-helm.sh/helm/v3 v3.17.1/go.mod h1:nvreuhuR+j78NkQcLC3TYoprCKStLyw5P4T7E5itv2w=
-k8s.io/api v0.32.1 h1:f562zw9cy+GvXzXf0CKlVQ7yHJVYzLfL6JAS4kOAaOc=
-k8s.io/api v0.32.1/go.mod h1:/Yi/BqkuueW1BgpoePYBRdDYfjPF5sgTr5+YqDZra5k=
-k8s.io/apiextensions-apiserver v0.32.1 h1:hjkALhRUeCariC8DiVmb5jj0VjIc1N0DREP32+6UXZw=
-k8s.io/apiextensions-apiserver v0.32.1/go.mod h1:sxWIGuGiYov7Io1fAS2X06NjMIk5CbRHc2StSmbaQto=
-k8s.io/apimachinery v0.32.1 h1:683ENpaCBjma4CYqsmZyhEzrGz6cjn1MY/X2jB2hkZs=
-k8s.io/apimachinery v0.32.1/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
-k8s.io/apiserver v0.32.1 h1:oo0OozRos66WFq87Zc5tclUX2r0mymoVHRq8JmR7Aak=
-k8s.io/apiserver v0.32.1/go.mod h1:UcB9tWjBY7aryeI5zAgzVJB/6k7E97bkr1RgqDz0jPw=
-k8s.io/cli-runtime v0.32.1 h1:19nwZPlYGJPUDbhAxDIS2/oydCikvKMHsxroKNGA2mM=
-k8s.io/cli-runtime v0.32.1/go.mod h1:NJPbeadVFnV2E7B7vF+FvU09mpwYlZCu8PqjzfuOnkY=
-k8s.io/client-go v0.32.1 h1:otM0AxdhdBIaQh7l1Q0jQpmo7WOFIk5FFa4bg6YMdUU=
-k8s.io/client-go v0.32.1/go.mod h1:aTTKZY7MdxUaJ/KiUs8D+GssR9zJZi77ZqtzcGXIiDg=
-k8s.io/component-base v0.32.1 h1:/5IfJ0dHIKBWysGV0yKTFfacZ5yNV1sulPh3ilJjRZk=
-k8s.io/component-base v0.32.1/go.mod h1:j1iMMHi/sqAHeG5z+O9BFNCF698a1u0186zkjMZQ28w=
+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.2 h1:bZrMLEkgizC24G9eViHGOPbW+aRo9duEISRIJKfdJuw=
+k8s.io/api v0.32.2/go.mod h1:hKlhk4x1sJyYnHENsrdCWw31FEmCijNGPJO5WzHiJ6Y=
+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.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ=
+k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
+k8s.io/apiserver v0.32.2 h1:WzyxAu4mvLkQxwD9hGa4ZfExo3yZZaYzoYvvVDlM6vw=
+k8s.io/apiserver v0.32.2/go.mod h1:PEwREHiHNU2oFdte7BjzA1ZyjWjuckORLIK/wLV5goM=
+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.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA=
+k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94=
+k8s.io/component-base v0.32.2 h1:1aUL5Vdmu7qNo4ZsE+569PV5zFatM9hl+lb3dEea2zU=
+k8s.io/component-base v0.32.2/go.mod h1:PXJ61Vx9Lg+P5mS8TLd7bCIr+eMJRQTyXe8KvkrvJq0=
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.1 h1:/btLtXLQUU1rWx8AEvX9jrb9LaI6yeezt3sFALhB8M8=
-k8s.io/kubectl v0.32.1/go.mod h1:sezNuyWi1STk4ZNPVRIFfgjqMI6XMf+oCVLjZen/pFQ=
-k8s.io/metrics v0.32.1 h1:Ou4nrEtZS2vFf7OJCf9z3+2kr0A00kQzfoSwxg0gXps=
-k8s.io/metrics v0.32.1/go.mod h1:cLnai9XKYby1tNMX+xe8p9VLzTqrxYPcmqfCBoWObcM=
+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/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=
From 5a2318d01f89d9798e9af68b442cdf2cafe7ac7b Mon Sep 17 00:00:00 2001
From: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Date: Tue, 15 Apr 2025 02:50:14 +0100
Subject: [PATCH 093/212] Update bug report template for 2.27.4 (#646)
---
.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 76b2e0f69..435e22465 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -96,6 +96,7 @@ body:
options:
- '2.28.1'
- '2.28.0'
+ - '2.27.4'
- '2.27.3'
- '2.27.2'
- '2.27.1'
From bfa55f8c67c1f989adeeb5c8aeb9cfb17bae6392 Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Tue, 15 Apr 2025 17:16:04 -0300
Subject: [PATCH 094/212] fix(logs): remove duplicated code BE-11821 (#653)
---
api/cmd/portainer/main.go | 9 +++++----
api/{cmd/portainer => logs}/log.go | 8 ++++----
2 files changed, 9 insertions(+), 8 deletions(-)
rename api/{cmd/portainer => logs}/log.go (91%)
diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go
index f16b3db0f..99f809c7c 100644
--- a/api/cmd/portainer/main.go
+++ b/api/cmd/portainer/main.go
@@ -39,6 +39,7 @@ import (
"github.com/portainer/portainer/api/kubernetes"
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/ldap"
+ "github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/api/oauth"
"github.com/portainer/portainer/api/pendingactions"
"github.com/portainer/portainer/api/pendingactions/actions"
@@ -581,13 +582,13 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
}
func main() {
- configureLogger()
- setLoggingMode("PRETTY")
+ logs.ConfigureLogger()
+ logs.SetLoggingMode("PRETTY")
flags := initCLI()
- setLoggingLevel(*flags.LogLevel)
- setLoggingMode(*flags.LogMode)
+ logs.SetLoggingLevel(*flags.LogLevel)
+ logs.SetLoggingMode(*flags.LogMode)
for {
server := buildServer(flags)
diff --git a/api/cmd/portainer/log.go b/api/logs/log.go
similarity index 91%
rename from api/cmd/portainer/log.go
rename to api/logs/log.go
index b5b4121ea..b44e6dc8c 100644
--- a/api/cmd/portainer/log.go
+++ b/api/logs/log.go
@@ -1,4 +1,4 @@
-package main
+package logs
import (
"fmt"
@@ -10,7 +10,7 @@ import (
"github.com/rs/zerolog/pkgerrors"
)
-func configureLogger() {
+func ConfigureLogger() {
zerolog.ErrorStackFieldName = "stack_trace"
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
@@ -21,7 +21,7 @@ func configureLogger() {
log.Logger = log.Logger.With().Caller().Stack().Logger()
}
-func setLoggingLevel(level string) {
+func SetLoggingLevel(level string) {
switch level {
case "ERROR":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
@@ -34,7 +34,7 @@ func setLoggingLevel(level string) {
}
}
-func setLoggingMode(mode string) {
+func SetLoggingMode(mode string) {
switch mode {
case "PRETTY":
log.Logger = log.Output(zerolog.ConsoleWriter{
From 66dee6fd065a80f1fd73dd90c8592bc5a3a9c477 Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Tue, 15 Apr 2025 21:27:30 -0300
Subject: [PATCH 095/212] fix(codemirror): optimize the autocompletion
performance R8S-294 (#650)
Co-authored-by: andres-portainer
---
app/react/components/CodeEditor.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/app/react/components/CodeEditor.tsx b/app/react/components/CodeEditor.tsx
index 09cc9591a..e74a70ec9 100644
--- a/app/react/components/CodeEditor.tsx
+++ b/app/react/components/CodeEditor.tsx
@@ -122,6 +122,9 @@ function schemaValidationExtensions(schema: JSONSchema7) {
if (Array.isArray(completions)) {
return null;
}
+
+ completions.validFor = /^\w*$/;
+
return completions;
},
],
From cf31700903a3f525805f8c2cc420974d13992e2f Mon Sep 17 00:00:00 2001
From: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Date: Wed, 16 Apr 2025 02:34:38 +0100
Subject: [PATCH 096/212] Update bug report template for 2.29.0 (#655)
---
.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 435e22465..58dc03d91 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.29.0'
- '2.28.1'
- '2.28.0'
- '2.27.4'
From be3e8e3332010ac411433fff15c9d8dbd3501b7f Mon Sep 17 00:00:00 2001
From: Devon Steenberg
Date: Wed, 16 Apr 2025 15:30:56 +1200
Subject: [PATCH 097/212] fix(proxy): don't forward sensitive headers
[BE-11819] (#654)
---
api/http/proxy/factory/reverse_proxy.go | 12 +-
api/http/proxy/factory/reverse_proxy_test.go | 116 +++++++++++++++++++
2 files changed, 126 insertions(+), 2 deletions(-)
create mode 100644 api/http/proxy/factory/reverse_proxy_test.go
diff --git a/api/http/proxy/factory/reverse_proxy.go b/api/http/proxy/factory/reverse_proxy.go
index fdee70c4d..d583d75fe 100644
--- a/api/http/proxy/factory/reverse_proxy.go
+++ b/api/http/proxy/factory/reverse_proxy.go
@@ -11,8 +11,13 @@ import (
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
+ return &httputil.ReverseProxy{Director: createDirector(target)}
+}
+
+func createDirector(target *url.URL) func(*http.Request) {
+ sensitiveHeaders := []string{"Cookie", "X-Csrf-Token"}
targetQuery := target.RawQuery
- director := func(req *http.Request) {
+ return func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
@@ -26,8 +31,11 @@ func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseP
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
+
+ for _, header := range sensitiveHeaders {
+ delete(req.Header, header)
+ }
}
- return &httputil.ReverseProxy{Director: director}
}
// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go
diff --git a/api/http/proxy/factory/reverse_proxy_test.go b/api/http/proxy/factory/reverse_proxy_test.go
new file mode 100644
index 000000000..1a3d88ba0
--- /dev/null
+++ b/api/http/proxy/factory/reverse_proxy_test.go
@@ -0,0 +1,116 @@
+package factory
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func Test_createDirector(t *testing.T) {
+ testCases := []struct {
+ name string
+ target *url.URL
+ req *http.Request
+ expectedReq *http.Request
+ }{
+ {
+ name: "base case",
+ target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
+ req: createRequest(
+ t,
+ "GET",
+ "https://agent-portainer.io/test?c=7",
+ map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
+ ),
+ expectedReq: createRequest(
+ t,
+ "GET",
+ "https://portainer.io/api/docker/test?a=5&b=6&c=7",
+ map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
+ ),
+ },
+ {
+ name: "no User-Agent",
+ target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
+ req: createRequest(
+ t,
+ "GET",
+ "https://agent-portainer.io/test?c=7",
+ map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
+ ),
+ expectedReq: createRequest(
+ t,
+ "GET",
+ "https://portainer.io/api/docker/test?a=5&b=6&c=7",
+ map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
+ ),
+ },
+ {
+ name: "Sensitive Headers",
+ target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
+ req: createRequest(
+ t,
+ "GET",
+ "https://agent-portainer.io/test?c=7",
+ map[string]string{
+ "Accept-Encoding": "gzip",
+ "Accept": "application/json",
+ "User-Agent": "something",
+ "Cookie": "junk",
+ "X-Csrf-Token": "junk",
+ },
+ ),
+ expectedReq: createRequest(
+ t,
+ "GET",
+ "https://portainer.io/api/docker/test?a=5&b=6&c=7",
+ map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
+ ),
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ director := createDirector(tc.target)
+ director(tc.req)
+
+ if diff := cmp.Diff(tc.req, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" {
+ t.Fatalf("requests are different: \n%s", diff)
+ }
+ })
+ }
+}
+
+func createURL(t *testing.T, urlString string) *url.URL {
+ parsedURL, err := url.Parse(urlString)
+ if err != nil {
+ t.Fatalf("Failed to create url: %s", err)
+ }
+
+ return parsedURL
+}
+
+func createRequest(t *testing.T, method, url string, headers map[string]string) *http.Request {
+ req, err := http.NewRequest(method, url, nil)
+ if err != nil {
+ t.Fatalf("Failed to create http request: %s", err)
+ } else {
+ for k, v := range headers {
+ req.Header.Add(k, v)
+ }
+ }
+
+ return req
+}
+
+func compareRequests(a, b *http.Request) bool {
+ methodEqual := a.Method == b.Method
+ urlEqual := cmp.Diff(a.URL, b.URL) == ""
+ hostEqual := a.Host == b.Host
+ protoEqual := a.Proto == b.Proto && a.ProtoMajor == b.ProtoMajor && a.ProtoMinor == b.ProtoMinor
+ headersEqual := cmp.Diff(a.Header, b.Header) == ""
+
+ return methodEqual && urlEqual && hostEqual && protoEqual && headersEqual
+}
From 01afe34df7ec2b92723c171e6e6e884172fb1e7a Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Thu, 17 Apr 2025 12:29:37 +1200
Subject: [PATCH 098/212] fix(namespaces): fix service not found error
[r8s-296] (#664)
---
app/react/kubernetes/services/useNamespaceServices.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/app/react/kubernetes/services/useNamespaceServices.ts b/app/react/kubernetes/services/useNamespaceServices.ts
index 5da0dcdd1..528fb2c35 100644
--- a/app/react/kubernetes/services/useNamespaceServices.ts
+++ b/app/react/kubernetes/services/useNamespaceServices.ts
@@ -32,6 +32,9 @@ export async function getServices(
environmentId: EnvironmentId,
namespace: string
) {
+ if (!namespace) {
+ return [];
+ }
try {
const { data: services } = await axios.get(
`kubernetes/${environmentId}/namespaces/${namespace}/services`
From 6fcf1893d32e3afe3aa8d9560e49442b1de8f49a Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Fri, 18 Apr 2025 17:34:34 -0300
Subject: [PATCH 099/212] fix(code): remove duplicated code BE-11821 (#667)
---
api/http/middlewares/withitem.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/http/middlewares/withitem.go b/api/http/middlewares/withitem.go
index 34e6a9ba2..fce95e86b 100644
--- a/api/http/middlewares/withitem.go
+++ b/api/http/middlewares/withitem.go
@@ -45,7 +45,7 @@ func WithItem[TId ~int, TObject any](getter ItemGetter[TId, TObject], idParam st
}
}
-func FetchItem[T any](request *http.Request, contextKey string) (*T, error) {
+func FetchItem[T any](request *http.Request, contextKey ItemContextKey) (*T, error) {
contextData := request.Context().Value(contextKey)
if contextData == nil {
return nil, errors.New("unable to find item in request context")
From bbe94f55b64a1784098cfc58cec9d3aae3a0ac98 Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Tue, 22 Apr 2025 09:52:52 +1200
Subject: [PATCH 100/212] feat(helm): uninstall helm app from details view
[r8s-285] (#648)
---
.../queries/useDeleteApplicationsMutation.ts | 22 +++------
.../ChartActions/ChartActions.tsx | 30 ++++++++++++
.../ChartActions/UninstallButton.tsx | 47 +++++++++++++++++++
.../HelmApplicationView.tsx | 13 ++++-
.../queries/useUninstallHelmAppMutation.ts | 39 +++++++++++++++
5 files changed, 133 insertions(+), 18 deletions(-)
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/UninstallButton.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/queries/useUninstallHelmAppMutation.ts
diff --git a/app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts b/app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts
index 047cf1a44..fcaf9df8f 100644
--- a/app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts
+++ b/app/react/kubernetes/applications/queries/useDeleteApplicationsMutation.ts
@@ -7,6 +7,7 @@ import { getAllSettledItems } from '@/portainer/helpers/promise-utils';
import { withGlobalError } from '@/react-tools/react-query';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { pluralize } from '@/portainer/helpers/strings';
+import { uninstallHelmApplication } from '@/react/kubernetes/helm/HelmApplicationView/queries/useUninstallHelmAppMutation';
import { parseKubernetesAxiosError } from '../../axiosError';
import { ApplicationRowData } from '../ListView/ApplicationsDatatable/types';
@@ -225,7 +226,11 @@ async function deleteApplication(
await deletePodApplication(application, stacks, environmentId);
break;
case 'Helm':
- await uninstallHelmApplication(application, environmentId);
+ await uninstallHelmApplication(
+ environmentId,
+ application.Name,
+ application.ResourcePool
+ );
break;
default:
throw new Error(
@@ -266,21 +271,6 @@ async function deletePodApplication(
}
}
-async function uninstallHelmApplication(
- application: ApplicationRowData,
- environmentId: EnvironmentId
-) {
- try {
- await axios.delete(
- `/endpoints/${environmentId}/kubernetes/helm/${application.Name}`,
- { params: { namespace: application.ResourcePool } }
- );
- } catch (error) {
- // parseAxiosError, because it's a regular portainer api error
- throw parseAxiosError(error, 'Unable to remove application');
- }
-}
-
async function deleteHorizontalPodAutoscaler(
hpa: HorizontalPodAutoscaler,
environmentId: EnvironmentId
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx
new file mode 100644
index 000000000..c67984952
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx
@@ -0,0 +1,30 @@
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { useAuthorizations } from '@/react/hooks/useUser';
+
+import { UninstallButton } from './UninstallButton';
+
+export function ChartActions({
+ environmentId,
+ releaseName,
+ namespace,
+}: {
+ environmentId: EnvironmentId;
+ releaseName: string;
+ namespace?: string;
+}) {
+ const { authorized } = useAuthorizations('K8sApplicationsW');
+
+ if (!authorized) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UninstallButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UninstallButton.tsx
new file mode 100644
index 000000000..fc2c6d5c2
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UninstallButton.tsx
@@ -0,0 +1,47 @@
+import { useRouter } from '@uirouter/react';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { notifySuccess } from '@/portainer/services/notifications';
+
+import { DeleteButton } from '@@/buttons/DeleteButton';
+
+import { useUninstallHelmAppMutation } from '../queries/useUninstallHelmAppMutation';
+
+export function UninstallButton({
+ environmentId,
+ releaseName,
+ namespace,
+}: {
+ environmentId: EnvironmentId;
+ releaseName: string;
+ namespace?: string;
+}) {
+ const uninstallHelmAppMutation = useUninstallHelmAppMutation(environmentId);
+ const router = useRouter();
+
+ return (
+
+ Uninstall
+
+ );
+
+ function handleUninstall() {
+ uninstallHelmAppMutation.mutate(
+ { releaseName, namespace },
+ {
+ onSuccess: () => {
+ router.stateService.go('kubernetes.applications', {
+ endpointId: environmentId,
+ });
+ notifySuccess('Success', 'Helm chart uninstalled successfully');
+ },
+ }
+ );
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
index 2fc5fb601..6aec1d50a 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
@@ -12,6 +12,7 @@ import { Alert } from '@@/Alert';
import { HelmSummary } from './HelmSummary';
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
import { useHelmRelease } from './queries/useHelmRelease';
+import { ChartActions } from './ChartActions/ChartActions';
export function HelmApplicationView() {
const environmentId = useEnvironmentId();
@@ -32,8 +33,16 @@ export function HelmApplicationView() {
- {name && }
-
+ {name && (
+
+
+
+ )}
+
uninstallHelmApplication(environmentId, releaseName, namespace),
+ ...withInvalidate(queryClient, [
+ applicationsQueryKeys.applications(environmentId),
+ ]),
+ ...withGlobalError('Unable to uninstall helm application'),
+ });
+}
+
+export async function uninstallHelmApplication(
+ environmentId: EnvironmentId,
+ releaseName: string,
+ namespace?: string
+) {
+ try {
+ await axios.delete(
+ `/endpoints/${environmentId}/kubernetes/helm/${releaseName}`,
+ { params: { namespace } }
+ );
+ } catch (error) {
+ // parseAxiosError, because it's a regular portainer api error
+ throw parseAxiosError(error, 'Unable to remove application');
+ }
+}
From 342549b5460938855d897bb701f4507311fb9313 Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Mon, 21 Apr 2025 18:59:51 -0300
Subject: [PATCH 101/212] fix(validate): remove dead code BE-11824 (#671)
---
api/datastore/validate/validate.go | 15 -----
api/datastore/validate/validate_test.go | 61 ---------------------
api/datastore/validate/validationMethods.go | 17 ------
go.mod | 4 --
go.sum | 12 ----
5 files changed, 109 deletions(-)
delete mode 100644 api/datastore/validate/validate.go
delete mode 100644 api/datastore/validate/validate_test.go
delete mode 100644 api/datastore/validate/validationMethods.go
diff --git a/api/datastore/validate/validate.go b/api/datastore/validate/validate.go
deleted file mode 100644
index 2b37311fe..000000000
--- a/api/datastore/validate/validate.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package validate
-
-import (
- "github.com/go-playground/validator/v10"
- portainer "github.com/portainer/portainer/api"
-)
-
-var validate *validator.Validate
-
-func ValidateLDAPSettings(ldp *portainer.LDAPSettings) error {
- validate = validator.New()
- registerValidationMethods(validate)
-
- return validate.Struct(ldp)
-}
diff --git a/api/datastore/validate/validate_test.go b/api/datastore/validate/validate_test.go
deleted file mode 100644
index 3fa7bd425..000000000
--- a/api/datastore/validate/validate_test.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package validate
-
-import (
- "testing"
-
- portainer "github.com/portainer/portainer/api"
-)
-
-func TestValidateLDAPSettings(t *testing.T) {
-
- tests := []struct {
- name string
- ldap portainer.LDAPSettings
- wantErr bool
- }{
- {
- name: "Empty LDAP Settings",
- ldap: portainer.LDAPSettings{},
- wantErr: true,
- },
- {
- name: "With URL",
- ldap: portainer.LDAPSettings{
- AnonymousMode: true,
- URL: "192.168.0.1:323",
- },
- wantErr: false,
- },
- {
- name: "Validate URL and URLs",
- ldap: portainer.LDAPSettings{
- AnonymousMode: true,
- URL: "192.168.0.1:323",
- },
- wantErr: false,
- },
- {
- name: "validate client ldap",
- ldap: portainer.LDAPSettings{
- AnonymousMode: false,
- ReaderDN: "CN=LDAP API Service Account",
- Password: "Qu**dfUUU**",
- URL: "aukdc15.pgc.co:389",
- TLSConfig: portainer.TLSConfiguration{
- TLS: false,
- TLSSkipVerify: false,
- },
- },
- wantErr: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := ValidateLDAPSettings(&tt.ldap)
- if (err == nil) == tt.wantErr {
- t.Errorf("No error expected but got %s", err)
- }
- })
- }
-}
diff --git a/api/datastore/validate/validationMethods.go b/api/datastore/validate/validationMethods.go
deleted file mode 100644
index 36abe3a54..000000000
--- a/api/datastore/validate/validationMethods.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package validate
-
-import (
- "github.com/go-playground/validator/v10"
-)
-
-func registerValidationMethods(v *validator.Validate) {
- v.RegisterValidation("validate_bool", ValidateBool)
-}
-
-/**
- * Validation methods below are being used for custom validation
- */
-func ValidateBool(fl validator.FieldLevel) bool {
- _, ok := fl.Field().Interface().(bool)
- return ok
-}
diff --git a/go.mod b/go.mod
index 22ed99fa2..d5bc8ab27 100644
--- a/go.mod
+++ b/go.mod
@@ -23,7 +23,6 @@ require (
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
github.com/go-git/go-git/v5 v5.13.0
github.com/go-ldap/ldap/v3 v3.4.1
- github.com/go-playground/validator/v10 v10.12.0
github.com/gofrs/uuid v4.2.0+incompatible
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.6.0
@@ -148,8 +147,6 @@ require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
- github.com/go-playground/locales v0.14.1 // indirect
- github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
@@ -185,7 +182,6 @@ require (
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
- github.com/leodido/go-urn v1.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/mailru/easyjson v0.7.7 // indirect
diff --git a/go.sum b/go.sum
index 2ca59e4f6..5fbd993f1 100644
--- a/go.sum
+++ b/go.sum
@@ -284,14 +284,6 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
-github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
-github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
-github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
-github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
-github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
-github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.12.0 h1:E4gtWgxWxp8YSxExrQFv5BpCahla0PVF2oTTEYaWQGI=
-github.com/go-playground/validator/v10 v10.12.0/go.mod h1:hCAPuzYvKdP33pxWa+2+6AIKXEKqjIUyqsNCtbsSJrA=
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
@@ -446,8 +438,6 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
-github.com/leodido/go-urn v1.2.2 h1:7z68G0FCGvDk646jz1AelTYNYWrTNm0bEcFAo147wt4=
-github.com/leodido/go-urn v1.2.2/go.mod h1:kUaIbLZWttglzwNuG0pgsh5vuV6u2YcGBYz1hIPjtOQ=
github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@@ -622,7 +612,6 @@ github.com/rubenv/sql-migrate v1.7.1 h1:f/o0WgfO/GqNuVg+6801K/KW3WdDSupzSjDYODmi
github.com/rubenv/sql-migrate v1.7.1/go.mod h1:Ob2Psprc0/3ggbM6wCzyYVFFuc6FyZrb2AS+ezLDFb4=
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/rwtodd/Go.Sed v0.0.0-20210816025313-55464686f9ef/go.mod h1:8AEUvGVi2uQ5b24BIhcr0GCcpd/RNAFWaN2CJFrWIIQ=
github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA=
github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
@@ -678,7 +667,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI=
From e319a7a5aeba9512869768dc003fb9f9a2b9dd2a Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Mon, 21 Apr 2025 19:27:14 -0300
Subject: [PATCH 102/212] fix(linter): enable ineffassign BE-10204 (#669)
---
.golangci.yaml | 1 +
api/datastore/migrator/migrate_dbversion20.go | 26 ++++++++++++-------
.../handler/kubernetes/deprecated_routes.go | 7 +++--
api/http/security/bouncer_test.go | 3 +++
4 files changed, 26 insertions(+), 11 deletions(-)
diff --git a/.golangci.yaml b/.golangci.yaml
index 648df24d1..b3d9bbfe3 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -12,6 +12,7 @@ linters:
- copyloopvar
- intrange
- perfsprint
+ - ineffassign
linters-settings:
depguard:
diff --git a/api/datastore/migrator/migrate_dbversion20.go b/api/datastore/migrator/migrate_dbversion20.go
index 1f02b8885..bad999c26 100644
--- a/api/datastore/migrator/migrate_dbversion20.go
+++ b/api/datastore/migrator/migrate_dbversion20.go
@@ -18,8 +18,7 @@ func (m *Migrator) updateResourceControlsToDBVersion22() error {
for _, resourceControl := range legacyResourceControls {
resourceControl.AdministratorsOnly = false
- err := m.resourceControlService.Update(resourceControl.ID, &resourceControl)
- if err != nil {
+ if err := m.resourceControlService.Update(resourceControl.ID, &resourceControl); err != nil {
return err
}
}
@@ -42,8 +41,8 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error {
for _, user := range legacyUsers {
user.PortainerAuthorizations = authorization.DefaultPortainerAuthorizations()
- err = m.userService.Update(user.ID, &user)
- if err != nil {
+
+ if err := m.userService.Update(user.ID, &user); err != nil {
return err
}
}
@@ -52,38 +51,47 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error {
if err != nil {
return err
}
+
endpointAdministratorRole.Priority = 1
endpointAdministratorRole.Authorizations = authorization.DefaultEndpointAuthorizationsForEndpointAdministratorRole()
- err = m.roleService.Update(endpointAdministratorRole.ID, endpointAdministratorRole)
+ if err := m.roleService.Update(endpointAdministratorRole.ID, endpointAdministratorRole); err != nil {
+ return err
+ }
helpDeskRole, err := m.roleService.Read(portainer.RoleID(2))
if err != nil {
return err
}
+
helpDeskRole.Priority = 2
helpDeskRole.Authorizations = authorization.DefaultEndpointAuthorizationsForHelpDeskRole(settings.AllowVolumeBrowserForRegularUsers)
- err = m.roleService.Update(helpDeskRole.ID, helpDeskRole)
+ if err := m.roleService.Update(helpDeskRole.ID, helpDeskRole); err != nil {
+ return err
+ }
standardUserRole, err := m.roleService.Read(portainer.RoleID(3))
if err != nil {
return err
}
+
standardUserRole.Priority = 3
standardUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForStandardUserRole(settings.AllowVolumeBrowserForRegularUsers)
- err = m.roleService.Update(standardUserRole.ID, standardUserRole)
+ if err := m.roleService.Update(standardUserRole.ID, standardUserRole); err != nil {
+ return err
+ }
readOnlyUserRole, err := m.roleService.Read(portainer.RoleID(4))
if err != nil {
return err
}
+
readOnlyUserRole.Priority = 4
readOnlyUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole(settings.AllowVolumeBrowserForRegularUsers)
- err = m.roleService.Update(readOnlyUserRole.ID, readOnlyUserRole)
- if err != nil {
+ if err := m.roleService.Update(readOnlyUserRole.ID, readOnlyUserRole); err != nil {
return err
}
diff --git a/api/http/handler/kubernetes/deprecated_routes.go b/api/http/handler/kubernetes/deprecated_routes.go
index cc44d10e5..e471c2ecb 100644
--- a/api/http/handler/kubernetes/deprecated_routes.go
+++ b/api/http/handler/kubernetes/deprecated_routes.go
@@ -36,11 +36,14 @@ func deprecatedNamespaceParser(w http.ResponseWriter, r *http.Request) (string,
// Restore the original body for further use
bodyBytes, err := io.ReadAll(r.Body)
+ if err != nil {
+ return "", httperror.InternalServerError("Unable to read request body", err)
+ }
+
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
payload := models.K8sNamespaceDetails{}
- err = request.DecodeAndValidateJSONPayload(r, &payload)
- if err != nil {
+ if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return "", httperror.BadRequest("Invalid request. Unable to parse namespace payload", err)
}
namespaceName := payload.Name
diff --git a/api/http/security/bouncer_test.go b/api/http/security/bouncer_test.go
index 9553b90c5..4d84dcfee 100644
--- a/api/http/security/bouncer_test.go
+++ b/api/http/security/bouncer_test.go
@@ -344,6 +344,7 @@ func Test_apiKeyLookup(t *testing.T) {
req.Header.Add("x-api-key", rawAPIKey)
token, err := bouncer.apiKeyLookup(req)
+ require.NoError(t, err)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
@@ -358,6 +359,7 @@ func Test_apiKeyLookup(t *testing.T) {
req.Header.Add("x-api-key", rawAPIKey)
token, err := bouncer.apiKeyLookup(req)
+ require.NoError(t, err)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
@@ -372,6 +374,7 @@ func Test_apiKeyLookup(t *testing.T) {
req.Header.Add("x-api-key", rawAPIKey)
token, err := bouncer.apiKeyLookup(req)
+ require.NoError(t, err)
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
is.Equal(expectedToken, token)
From 9a9373dd0f6ef66b6f0d9edcda5c2c75ad4350ea Mon Sep 17 00:00:00 2001
From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Date: Tue, 22 Apr 2025 21:29:39 +1200
Subject: [PATCH 103/212] fix: cve-2025-22871 [BE-11825] (#678)
---
go.mod | 6 +++---
go.sum | 8 ++++----
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/go.mod b/go.mod
index d5bc8ab27..64c594902 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/portainer/portainer
-go 1.23.5
+go 1.23.8
require (
github.com/Masterminds/semver v1.5.0
@@ -26,7 +26,7 @@ require (
github.com/gofrs/uuid v4.2.0+incompatible
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.6.0
- github.com/gorilla/csrf v1.7.2
+ github.com/gorilla/csrf v1.7.3
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/go-version v1.7.0
@@ -274,7 +274,7 @@ require (
go.opentelemetry.io/otel/trace v1.28.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/mock v0.5.0 // indirect
- golang.org/x/net v0.37.0 // indirect
+ golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.30.0 // indirect
golang.org/x/text v0.23.0 // indirect
diff --git a/go.sum b/go.sum
index 5fbd993f1..3f4be510b 100644
--- a/go.sum
+++ b/go.sum
@@ -338,8 +338,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
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.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
-github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
+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/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
@@ -790,8 +790,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.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
-golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
+golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
From 61d6ac035d22e258007cbf070bbd87bff76b76c8 Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Wed, 23 Apr 2025 08:58:21 +1200
Subject: [PATCH 104/212] feat(helm): auto refresh helm resources [r8s-298]
(#672)
---
.../components/datatables/Datatable.test.tsx | 16 +++---
app/react/components/datatables/types.ts | 4 ++
.../ListView/ConfigsDatatable/store.ts | 17 +++---
.../ApplicationsStacksDatatable/types.ts | 12 -----
.../ReleaseDetails/ReleaseTabs.tsx | 2 +-
.../ResourcesTable/ResourcesTable.test.tsx | 38 +++++++++++++-
.../ResourcesTable/ResourcesTable.tsx | 52 +++++++++++++++----
.../ResourcesTable/columns/name.tsx | 14 +++--
.../ResourcesTable/useResourceRows.ts | 8 ++-
.../queries/useHelmRelease.ts | 7 ++-
10 files changed, 120 insertions(+), 50 deletions(-)
diff --git a/app/react/components/datatables/Datatable.test.tsx b/app/react/components/datatables/Datatable.test.tsx
index 67ff85f70..a40822848 100644
--- a/app/react/components/datatables/Datatable.test.tsx
+++ b/app/react/components/datatables/Datatable.test.tsx
@@ -9,10 +9,9 @@ import {
import { Datatable, defaultGlobalFilterFn, Props } from './Datatable';
import {
- BasicTableSettings,
createPersistedStore,
refreshableSettings,
- RefreshableTableSettings,
+ TableSettingsWithRefreshable,
} from './types';
import { useTableState } from './useTableState';
@@ -30,13 +29,14 @@ const mockColumns = [
];
// mock table settings / state
-export interface TableSettings
- extends BasicTableSettings,
- RefreshableTableSettings {}
function createStore(storageKey: string) {
- return createPersistedStore(storageKey, 'name', (set) => ({
- ...refreshableSettings(set),
- }));
+ return createPersistedStore(
+ storageKey,
+ 'name',
+ (set) => ({
+ ...refreshableSettings(set),
+ })
+ );
}
const storageKey = 'test-table';
const settingsStore = createStore(storageKey);
diff --git a/app/react/components/datatables/types.ts b/app/react/components/datatables/types.ts
index 11dcac867..45264f337 100644
--- a/app/react/components/datatables/types.ts
+++ b/app/react/components/datatables/types.ts
@@ -99,6 +99,10 @@ export interface BasicTableSettings
extends SortableTableSettings,
PaginationTableSettings {}
+export interface TableSettingsWithRefreshable
+ extends BasicTableSettings,
+ RefreshableTableSettings {}
+
export function createPersistedStore(
storageKey: string,
initialSortBy?: string | { id: string; desc: boolean },
diff --git a/app/react/docker/configs/ListView/ConfigsDatatable/store.ts b/app/react/docker/configs/ListView/ConfigsDatatable/store.ts
index 66672630a..95a649f89 100644
--- a/app/react/docker/configs/ListView/ConfigsDatatable/store.ts
+++ b/app/react/docker/configs/ListView/ConfigsDatatable/store.ts
@@ -1,16 +1,15 @@
import {
- BasicTableSettings,
createPersistedStore,
refreshableSettings,
- RefreshableTableSettings,
+ TableSettingsWithRefreshable,
} from '@@/datatables/types';
-export interface TableSettings
- extends BasicTableSettings,
- RefreshableTableSettings {}
-
export function createStore(storageKey: string) {
- return createPersistedStore(storageKey, 'name', (set) => ({
- ...refreshableSettings(set),
- }));
+ return createPersistedStore(
+ storageKey,
+ 'name',
+ (set) => ({
+ ...refreshableSettings(set),
+ })
+ );
}
diff --git a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts
index 5f6f4736b..75f0a882e 100644
--- a/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts
+++ b/app/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/types.ts
@@ -1,17 +1,5 @@
-import { SystemResourcesTableSettings } from '@/react/kubernetes/datatables/SystemResourcesSettings';
-
-import {
- BasicTableSettings,
- RefreshableTableSettings,
-} from '@@/datatables/types';
-
import { Application } from '../ApplicationsDatatable/types';
-export interface TableSettings
- extends BasicTableSettings,
- RefreshableTableSettings,
- SystemResourcesTableSettings {}
-
export type Stack = {
Name: string;
ResourcePool: string;
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
index 6a954d1fc..efa36c0c2 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
@@ -25,7 +25,7 @@ function helmTabs(
{
label: 'Resources',
id: 'resources',
- children: ,
+ children: ,
},
{
label: 'Values',
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx
index cf0dfde71..113776344 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.test.tsx
@@ -8,6 +8,24 @@ import { GenericResource } from '../../../types';
import { ResourcesTable } from './ResourcesTable';
+// Mock the necessary hooks
+const mockUseCurrentStateAndParams = vi.fn();
+const mockUseEnvironmentId = vi.fn();
+const mockUseHelmRelease = vi.fn();
+
+vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({
+ ...(await importOriginal()),
+ useCurrentStateAndParams: () => mockUseCurrentStateAndParams(),
+}));
+
+vi.mock('@/react/hooks/useEnvironmentId', () => ({
+ useEnvironmentId: () => mockUseEnvironmentId(),
+}));
+
+vi.mock('../../queries/useHelmRelease', () => ({
+ useHelmRelease: () => mockUseHelmRelease(),
+}));
+
const successResources = [
{
kind: 'ValidatingWebhookConfiguration',
@@ -108,8 +126,26 @@ const failedResources = [
];
function renderResourcesTable(resources: GenericResource[]) {
+ // Setup mock return values
+ mockUseEnvironmentId.mockReturnValue(3);
+ mockUseCurrentStateAndParams.mockReturnValue({
+ params: {
+ name: 'test-release',
+ namespace: 'default',
+ },
+ });
+ mockUseHelmRelease.mockReturnValue({
+ data: {
+ info: {
+ resources,
+ },
+ },
+ isLoading: false,
+ error: null,
+ });
+
const Wrapped = withTestQueryProvider(withTestRouter(ResourcesTable));
- return render( );
+ return render( );
}
afterEach(() => {
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
index c8922ca04..6a3b35674 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
@@ -1,23 +1,47 @@
-import { Datatable } from '@@/datatables';
-import { createPersistedStore } from '@@/datatables/types';
+import { useCurrentStateAndParams } from '@uirouter/react';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { Datatable, TableSettingsMenu } from '@@/datatables';
+import {
+ createPersistedStore,
+ refreshableSettings,
+ TableSettingsWithRefreshable,
+} from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { Widget } from '@@/Widget';
+import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
-import { GenericResource } from '../../../types';
+import { useHelmRelease } from '../../queries/useHelmRelease';
import { columns } from './columns';
import { useResourceRows } from './useResourceRows';
-type Props = {
- resources: GenericResource[];
-};
-
const storageKey = 'helm-resources';
-const settingsStore = createPersistedStore(storageKey, 'resourceType');
-export function ResourcesTable({ resources }: Props) {
+export function createStore(storageKey: string) {
+ return createPersistedStore(
+ storageKey,
+ 'name',
+ (set) => ({
+ ...refreshableSettings(set),
+ })
+ );
+}
+
+const settingsStore = createStore('helm-resources');
+
+export function ResourcesTable() {
+ const environmentId = useEnvironmentId();
+ const { params } = useCurrentStateAndParams();
+ const { name, namespace } = params;
+
const tableState = useTableState(settingsStore, storageKey);
- const rows = useResourceRows(resources);
+ const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
+ showResources: true,
+ refetchInterval: tableState.autoRefreshRate * 1000,
+ });
+ const rows = useResourceRows(helmReleaseQuery.data?.info?.resources);
return (
@@ -32,6 +56,14 @@ export function ResourcesTable({ resources }: Props) {
disableSelect
getRowId={(row) => row.id}
data-cy="helm-resources-datatable"
+ renderTableSettings={() => (
+
+ tableState.setAutoRefreshRate(value)}
+ />
+
+ )}
/>
);
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/name.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/name.tsx
index 332e49f1c..0b8c2aaae 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/name.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/name.tsx
@@ -6,11 +6,15 @@ import { ResourceRow } from '../types';
import { columnHelper } from './helper';
-export const name = columnHelper.accessor((row) => row.name.label, {
- header: 'Name',
- cell: Cell,
- id: 'name',
-});
+// `${row.name.label}/${row.resourceType}` reduces shuffling when the name is the same but the resourceType is different
+export const name = columnHelper.accessor(
+ (row) => `${row.name.label}/${row.resourceType}`,
+ {
+ header: 'Name',
+ cell: Cell,
+ id: 'name',
+ }
+);
function Cell({ row }: CellContext) {
const { name } = row.original;
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts
index e5301dc05..ac90271f8 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/useResourceRows.ts
@@ -27,11 +27,15 @@ const statusToColorMap: Record = {
Unknown: 'mutedLite',
};
-export function useResourceRows(resources: GenericResource[]): ResourceRow[] {
+export function useResourceRows(resources?: GenericResource[]): ResourceRow[] {
return useMemo(() => getResourceRows(resources), [resources]);
}
-function getResourceRows(resources: GenericResource[]): ResourceRow[] {
+function getResourceRows(resources?: GenericResource[]): ResourceRow[] {
+ if (!resources) {
+ return [];
+ }
+
return resources.map(getResourceRow);
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
index f503aec7f..8d41e6544 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
@@ -16,19 +16,22 @@ export function useHelmRelease(
options: {
select?: (data: HelmRelease) => T;
showResources?: boolean;
+ refetchInterval?: number;
} = {}
) {
+ const { select, showResources, refetchInterval } = options;
return useQuery(
[environmentId, 'helm', 'releases', namespace, name, options.showResources],
() =>
getHelmRelease(environmentId, name, {
namespace,
- showResources: options.showResources,
+ showResources,
}),
{
enabled: !!environmentId && !!name && !!namespace,
...withGlobalError('Unable to retrieve helm application details'),
- select: options.select,
+ select,
+ refetchInterval,
}
);
}
From c91c8a6467cbe16026cec465b832ef01e9b39b9e Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Wed, 23 Apr 2025 08:58:34 +1200
Subject: [PATCH 105/212] feat(helm): rollback helm chart [r8s-287] (#660)
---
api/http/handler/helm/handler.go | 4 +
api/http/handler/helm/helm_rollback.go | 105 +++++++++++
.../ChartActions/ChartActions.tsx | 18 +-
.../ChartActions/RollbackButton.test.tsx | 163 ++++++++++++++++++
.../ChartActions/RollbackButton.tsx | 71 ++++++++
.../HelmApplicationView.tsx | 51 +++---
.../queries/useHelmRollbackMutation.ts | 61 +++++++
pkg/libhelm/options/rollback_options.go | 19 ++
pkg/libhelm/sdk/rollback.go | 111 ++++++++++++
pkg/libhelm/sdk/rollback_test.go | 123 +++++++++++++
pkg/libhelm/sdk/search_repo_test.go | 1 -
pkg/libhelm/test/mock.go | 5 +
pkg/libhelm/types/types.go | 1 +
13 files changed, 701 insertions(+), 32 deletions(-)
create mode 100644 api/http/handler/helm/helm_rollback.go
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.test.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRollbackMutation.ts
create mode 100644 pkg/libhelm/options/rollback_options.go
create mode 100644 pkg/libhelm/sdk/rollback.go
create mode 100644 pkg/libhelm/sdk/rollback_test.go
diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go
index e87e98410..f7f14a1c1 100644
--- a/api/http/handler/helm/handler.go
+++ b/api/http/handler/helm/handler.go
@@ -62,6 +62,10 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/{id}/kubernetes/helm/{release}/history",
httperror.LoggerHandler(h.helmGetHistory)).Methods(http.MethodGet)
+ // `helm rollback [RELEASE_NAME] [REVISION]`
+ h.Handle("/{id}/kubernetes/helm/{release}/rollback",
+ httperror.LoggerHandler(h.helmRollback)).Methods(http.MethodPost)
+
return h
}
diff --git a/api/http/handler/helm/helm_rollback.go b/api/http/handler/helm/helm_rollback.go
new file mode 100644
index 000000000..d98f7a47a
--- /dev/null
+++ b/api/http/handler/helm/helm_rollback.go
@@ -0,0 +1,105 @@
+package helm
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ _ "github.com/portainer/portainer/pkg/libhelm/release"
+ httperror "github.com/portainer/portainer/pkg/libhttp/error"
+ "github.com/portainer/portainer/pkg/libhttp/request"
+ "github.com/portainer/portainer/pkg/libhttp/response"
+)
+
+// @id HelmRollback
+// @summary Rollback a helm release
+// @description Rollback a helm release to a previous revision
+// @description **Access policy**: authenticated
+// @tags helm
+// @security ApiKeyAuth || jwt
+// @produce json
+// @param id path int true "Environment(Endpoint) identifier"
+// @param release path string true "Helm release name"
+// @param namespace query string false "specify an optional namespace"
+// @param revision query int false "specify the revision to rollback to (defaults to previous revision if not specified)"
+// @param wait query boolean false "wait for resources to be ready (default: false)"
+// @param waitForJobs query boolean false "wait for jobs to complete before marking the release as successful (default: false)"
+// @param recreate query boolean false "performs pods restart for the resource if applicable (default: true)"
+// @param force query boolean false "force resource update through delete/recreate if needed (default: false)"
+// @param timeout query int false "time to wait for any individual Kubernetes operation in seconds (default: 300)"
+// @success 200 {object} release.Release "Success"
+// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
+// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
+// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
+// @failure 404 "Unable to find an environment with the specified identifier or release name."
+// @failure 500 "Server error occurred while attempting to rollback the release."
+// @router /endpoints/{id}/kubernetes/helm/{release}/rollback [post]
+func (handler *Handler) helmRollback(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+ release, err := request.RetrieveRouteVariableValue(r, "release")
+ if err != nil {
+ return httperror.BadRequest("No release specified", err)
+ }
+
+ clusterAccess, httperr := handler.getHelmClusterAccess(r)
+ if httperr != nil {
+ return httperr
+ }
+
+ // build the rollback options
+ rollbackOpts := options.RollbackOptions{
+ KubernetesClusterAccess: clusterAccess,
+ Name: release,
+ // Set default values
+ Recreate: true, // Default to recreate pods (restart)
+ Timeout: 5 * time.Minute, // Default timeout of 5 minutes
+ }
+
+ namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
+ // optional namespace. The library defaults to "default"
+ if namespace != "" {
+ rollbackOpts.Namespace = namespace
+ }
+
+ revision, _ := request.RetrieveNumericQueryParameter(r, "revision", true)
+ // optional revision. If not specified, it will rollback to the previous revision
+ if revision > 0 {
+ rollbackOpts.Version = revision
+ }
+
+ // Default for wait is false, only set to true if explicitly requested
+ wait, err := request.RetrieveBooleanQueryParameter(r, "wait", true)
+ if err == nil {
+ rollbackOpts.Wait = wait
+ }
+
+ // Default for waitForJobs is false, only set to true if explicitly requested
+ waitForJobs, err := request.RetrieveBooleanQueryParameter(r, "waitForJobs", true)
+ if err == nil {
+ rollbackOpts.WaitForJobs = waitForJobs
+ }
+
+ // Default for recreate is true (set above), override if specified
+ recreate, err := request.RetrieveBooleanQueryParameter(r, "recreate", true)
+ if err == nil {
+ rollbackOpts.Recreate = recreate
+ }
+
+ // Default for force is false, only set to true if explicitly requested
+ force, err := request.RetrieveBooleanQueryParameter(r, "force", true)
+ if err == nil {
+ rollbackOpts.Force = force
+ }
+
+ timeout, _ := request.RetrieveNumericQueryParameter(r, "timeout", true)
+ // Override default timeout if specified
+ if timeout > 0 {
+ rollbackOpts.Timeout = time.Duration(timeout) * time.Second
+ }
+
+ releaseInfo, err := handler.helmPackageManager.Rollback(rollbackOpts)
+ if err != nil {
+ return httperror.InternalServerError("Failed to rollback helm release", err)
+ }
+
+ return response.JSON(w, releaseInfo)
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx
index c67984952..59e0fd2dc 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/ChartActions.tsx
@@ -1,22 +1,20 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
-import { useAuthorizations } from '@/react/hooks/useUser';
+import { RollbackButton } from './RollbackButton';
import { UninstallButton } from './UninstallButton';
export function ChartActions({
environmentId,
releaseName,
namespace,
+ currentRevision,
}: {
environmentId: EnvironmentId;
releaseName: string;
namespace?: string;
+ currentRevision?: number;
}) {
- const { authorized } = useAuthorizations('K8sApplicationsW');
-
- if (!authorized) {
- return null;
- }
+ const hasPreviousRevision = currentRevision && currentRevision >= 2;
return (
@@ -25,6 +23,14 @@ export function ChartActions({
releaseName={releaseName}
namespace={namespace}
/>
+ {hasPreviousRevision && (
+
+ )}
);
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.test.tsx
new file mode 100644
index 000000000..1d7b3f340
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.test.tsx
@@ -0,0 +1,163 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { HttpResponse, http } from 'msw';
+import { vi, type Mock } from 'vitest';
+
+import { server } from '@/setup-tests/server';
+import { notifySuccess } from '@/portainer/services/notifications';
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+
+import { confirm } from '@@/modals/confirm';
+
+import { RollbackButton } from './RollbackButton';
+
+// Mock the confirm modal function
+vi.mock('@@/modals/confirm', () => ({
+ confirm: vi.fn(() => Promise.resolve(false)),
+ buildConfirmButton: vi.fn((label) => ({ label })),
+}));
+
+// Mock the notifications service
+vi.mock('@/portainer/services/notifications', () => ({
+ notifySuccess: vi.fn(),
+}));
+
+function renderButton(props = {}) {
+ const defaultProps = {
+ latestRevision: 3, // So we're rolling back to revision 2
+ environmentId: 1,
+ releaseName: 'test-release',
+ namespace: 'default',
+ ...props,
+ };
+
+ const Wrapped = withTestQueryProvider(RollbackButton);
+ return render( );
+}
+
+describe('RollbackButton', () => {
+ test('should display the revision to rollback to', () => {
+ renderButton();
+
+ const button = screen.getByRole('button', { name: /Rollback to #2/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ test('should be disabled when the rollback mutation is loading', async () => {
+ const resolveRequest = vi.fn();
+ const requestPromise = new Promise((resolve) => {
+ resolveRequest.mockImplementation(() => resolve());
+ });
+
+ server.use(
+ http.post(
+ '/api/endpoints/1/kubernetes/helm/test-release/rollback',
+ () =>
+ new Promise((resolve) => {
+ // Keep request pending to simulate loading state
+ requestPromise
+ .then(() => {
+ resolve(HttpResponse.json({}));
+ return null;
+ })
+ .catch(() => {});
+ })
+ )
+ );
+
+ renderButton();
+
+ const user = userEvent.setup();
+ const button = screen.getByRole('button', { name: /Rollback to #2/i });
+
+ (confirm as Mock).mockResolvedValueOnce(true);
+ await user.click(button);
+
+ await waitFor(() => {
+ expect(screen.getByText('Rolling back...')).toBeInTheDocument();
+ });
+
+ resolveRequest();
+ });
+
+ test('should show a confirmation modal before executing the rollback', async () => {
+ renderButton();
+
+ const user = userEvent.setup();
+ const button = screen.getByRole('button', { name: /Rollback to #2/i });
+
+ await user.click(button);
+
+ expect(confirm).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Are you sure?',
+ message: expect.stringContaining(
+ 'Rolling back will restore the application to revision #2'
+ ),
+ })
+ );
+ });
+
+ test('should execute the rollback mutation with correct query params when confirmed', async () => {
+ let requestParams: Record = {};
+
+ server.use(
+ http.post(
+ '/api/endpoints/1/kubernetes/helm/test-release/rollback',
+ ({ request }) => {
+ const url = new URL(request.url);
+ requestParams = Object.fromEntries(url.searchParams.entries());
+ return HttpResponse.json({});
+ }
+ )
+ );
+
+ renderButton();
+
+ const user = userEvent.setup();
+ const button = screen.getByRole('button', { name: /Rollback to #2/i });
+
+ (confirm as Mock).mockResolvedValueOnce(true);
+ await user.click(button);
+
+ await waitFor(() => {
+ expect(Object.keys(requestParams).length).toBeGreaterThan(0);
+ });
+
+ expect(requestParams.namespace).toBe('default');
+ expect(requestParams.revision).toBe('2');
+
+ expect(notifySuccess).toHaveBeenCalledWith(
+ 'Success',
+ 'Application rolled back to revision #2 successfully.'
+ );
+ });
+
+ test('should not execute the rollback if confirmation is cancelled', async () => {
+ let wasRequestMade = false;
+
+ server.use(
+ http.post(
+ '/api/endpoints/1/kubernetes/helm/test-release/rollback',
+ () => {
+ wasRequestMade = true;
+ return HttpResponse.json({});
+ }
+ )
+ );
+
+ renderButton();
+
+ const user = userEvent.setup();
+ const button = screen.getByRole('button', { name: /Rollback to #2/i });
+
+ (confirm as Mock).mockResolvedValueOnce(false);
+ await user.click(button);
+
+ await waitFor(() => {
+ expect(confirm).toHaveBeenCalled();
+ });
+
+ expect(wasRequestMade).toBe(false);
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx
new file mode 100644
index 000000000..3d870d3a8
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx
@@ -0,0 +1,71 @@
+import { RotateCcw } from 'lucide-react';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { notifySuccess } from '@/portainer/services/notifications';
+
+import { LoadingButton } from '@@/buttons';
+import { buildConfirmButton } from '@@/modals/utils';
+import { confirm } from '@@/modals/confirm';
+import { ModalType } from '@@/modals';
+
+import { useHelmRollbackMutation } from '../queries/useHelmRollbackMutation';
+
+type Props = {
+ latestRevision: number;
+ environmentId: EnvironmentId;
+ releaseName: string;
+ namespace?: string;
+};
+
+export function RollbackButton({
+ latestRevision,
+ environmentId,
+ releaseName,
+ namespace,
+}: Props) {
+ // the selectedRevision can be a prop when selecting a revision is implemented
+ const selectedRevision = latestRevision ? latestRevision - 1 : undefined;
+
+ const rollbackMutation = useHelmRollbackMutation(environmentId);
+
+ return (
+
+ Rollback to #{selectedRevision}
+
+ );
+
+ async function handleClick() {
+ const confirmed = await confirm({
+ title: 'Are you sure?',
+ modalType: ModalType.Warn,
+ confirmButton: buildConfirmButton('Rollback'),
+ message: `Rolling back will restore the application to revision #${selectedRevision}, which will cause service interruption. Do you wish to continue?`,
+ });
+ if (!confirmed) {
+ return;
+ }
+
+ rollbackMutation.mutate(
+ {
+ releaseName,
+ params: { namespace, revision: selectedRevision },
+ },
+ {
+ onSuccess: () => {
+ notifySuccess(
+ 'Success',
+ `Application rolled back to revision #${selectedRevision} successfully.`
+ );
+ },
+ }
+ );
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
index 6aec1d50a..164943223 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
@@ -3,12 +3,14 @@ import { useCurrentStateAndParams } from '@uirouter/react';
import helm from '@/assets/ico/vendor/helm.svg?c';
import { PageHeader } from '@/react/components/PageHeader';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
-import { EnvironmentId } from '@/react/portainer/environments/types';
+import { Authorized } from '@/react/hooks/useUser';
import { WidgetTitle, WidgetBody, Widget, Loading } from '@@/Widget';
import { Card } from '@@/Card';
import { Alert } from '@@/Alert';
+import { HelmRelease } from '../types';
+
import { HelmSummary } from './HelmSummary';
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
import { useHelmRelease } from './queries/useHelmRelease';
@@ -19,6 +21,10 @@ export function HelmApplicationView() {
const { params } = useCurrentStateAndParams();
const { name, namespace } = params;
+ const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
+ showResources: true,
+ });
+
return (
<>
{name && (
-
+
+
+
)}
@@ -57,21 +66,13 @@ export function HelmApplicationView() {
}
type HelmDetailsProps = {
- name: string;
- namespace: string;
- environmentId: EnvironmentId;
+ isLoading: boolean;
+ isError: boolean;
+ release: HelmRelease | undefined;
};
-function HelmDetails({ name, namespace, environmentId }: HelmDetailsProps) {
- const {
- data: release,
- isInitialLoading,
- isError,
- } = useHelmRelease(environmentId, name, namespace, {
- showResources: true,
- });
-
- if (isInitialLoading) {
+function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) {
+ if (isLoading) {
return
;
}
@@ -81,16 +82,16 @@ function HelmDetails({ name, namespace, environmentId }: HelmDetailsProps) {
);
}
- if (!release) {
+ if (!data) {
return
;
}
return (
<>
-
+
-
+
>
);
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRollbackMutation.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRollbackMutation.ts
new file mode 100644
index 000000000..6aea3ad29
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRollbackMutation.ts
@@ -0,0 +1,61 @@
+import { useMutation } from '@tanstack/react-query';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import {
+ queryClient,
+ withInvalidate,
+ withGlobalError,
+} from '@/react-tools/react-query';
+import axios from '@/portainer/services/axios';
+import { queryKeys } from '@/react/kubernetes/applications/queries/query-keys';
+
+/**
+ * Parameters for helm rollback operation
+ *
+ * @see https://helm.sh/docs/helm/helm_rollback/
+ */
+interface RollbackQueryParams {
+ /** Optional namespace for the release (defaults to "default" if not specified) */
+ namespace?: string;
+ /** Revision to rollback to (if omitted or set to 0, rolls back to the previous release) */
+ revision?: number;
+ /** If set, waits until resources are in a ready state before marking the release as successful (default: false) */
+ wait?: boolean;
+ /** If set and --wait enabled, waits until all Jobs have been completed before marking the release as successful (default: false) */
+ waitForJobs?: boolean;
+ /** Performs pods restart for the resources if applicable (default: true) */
+ recreate?: boolean;
+ /** Force resource update through delete/recreate if needed (default: false) */
+ force?: boolean;
+ /** Time to wait for any individual Kubernetes operation in seconds (default: 300) */
+ timeout?: number;
+}
+
+interface RollbackPayload {
+ releaseName: string;
+ params: RollbackQueryParams;
+}
+
+async function rollbackRelease({
+ releaseName,
+ params,
+ environmentId,
+}: RollbackPayload & { environmentId: EnvironmentId }) {
+ return axios.post
>(
+ `/endpoints/${environmentId}/kubernetes/helm/${releaseName}/rollback`,
+ null,
+ { params }
+ );
+}
+
+export function useHelmRollbackMutation(environmentId: EnvironmentId) {
+ return useMutation({
+ mutationFn: ({ releaseName, params }: RollbackPayload) =>
+ rollbackRelease({ releaseName, params, environmentId }),
+ ...withGlobalError('Unable to rollback Helm release'),
+ ...withInvalidate(queryClient, [
+ [environmentId, 'helm', 'releases'],
+ queryKeys.applications(environmentId),
+ ]),
+ });
+}
diff --git a/pkg/libhelm/options/rollback_options.go b/pkg/libhelm/options/rollback_options.go
new file mode 100644
index 000000000..1c220f223
--- /dev/null
+++ b/pkg/libhelm/options/rollback_options.go
@@ -0,0 +1,19 @@
+package options
+
+import "time"
+
+// RollbackOptions defines options for rollback.
+type RollbackOptions struct {
+ // Required
+ Name string
+ Namespace string
+ KubernetesClusterAccess *KubernetesClusterAccess
+
+ // Optional with defaults
+ Version int // Target revision to rollback to (0 means previous revision)
+ Timeout time.Duration // Default: 5 minutes
+ Wait bool // Default: false
+ WaitForJobs bool // Default: false
+ Recreate bool // Default: false - whether to recreate pods
+ Force bool // Default: false - whether to force recreation
+}
diff --git a/pkg/libhelm/sdk/rollback.go b/pkg/libhelm/sdk/rollback.go
new file mode 100644
index 000000000..9b5caac8e
--- /dev/null
+++ b/pkg/libhelm/sdk/rollback.go
@@ -0,0 +1,111 @@
+package sdk
+
+import (
+ "time"
+
+ "github.com/pkg/errors"
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/release"
+ "github.com/rs/zerolog/log"
+ "helm.sh/helm/v3/pkg/action"
+)
+
+// Rollback would implement the HelmPackageManager interface by using the Helm SDK to rollback a release to a previous revision.
+func (hspm *HelmSDKPackageManager) Rollback(rollbackOpts options.RollbackOptions) (*release.Release, error) {
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("name", rollbackOpts.Name).
+ Str("namespace", rollbackOpts.Namespace).
+ Int("revision", rollbackOpts.Version).
+ Bool("wait", rollbackOpts.Wait).
+ Msg("Rolling back Helm release")
+
+ if rollbackOpts.Name == "" {
+ log.Error().
+ Str("context", "HelmClient").
+ Msg("Name is required for helm release rollback")
+ return nil, errors.New("name is required for helm release rollback")
+ }
+
+ // Initialize action configuration with kubernetes config
+ actionConfig := new(action.Configuration)
+ err := hspm.initActionConfig(actionConfig, rollbackOpts.Namespace, rollbackOpts.KubernetesClusterAccess)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to initialize helm configuration for helm release rollback")
+ }
+
+ rollbackClient := initRollbackClient(actionConfig, rollbackOpts)
+
+ // Run the rollback
+ err = rollbackClient.Run(rollbackOpts.Name)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("name", rollbackOpts.Name).
+ Str("namespace", rollbackOpts.Namespace).
+ Int("revision", rollbackOpts.Version).
+ Err(err).
+ Msg("Failed to rollback helm release")
+ return nil, errors.Wrap(err, "helm was not able to rollback the release")
+ }
+
+ // Get the release info after rollback
+ statusClient := action.NewStatus(actionConfig)
+ rel, err := statusClient.Run(rollbackOpts.Name)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Str("name", rollbackOpts.Name).
+ Str("namespace", rollbackOpts.Namespace).
+ Int("revision", rollbackOpts.Version).
+ Err(err).
+ Msg("Failed to get status after rollback")
+ return nil, errors.Wrap(err, "failed to get status after rollback")
+ }
+
+ return &release.Release{
+ Name: rel.Name,
+ Namespace: rel.Namespace,
+ Version: rel.Version,
+ Info: &release.Info{
+ Status: release.Status(rel.Info.Status),
+ Notes: rel.Info.Notes,
+ Description: rel.Info.Description,
+ },
+ Manifest: rel.Manifest,
+ Chart: release.Chart{
+ Metadata: &release.Metadata{
+ Name: rel.Chart.Metadata.Name,
+ Version: rel.Chart.Metadata.Version,
+ AppVersion: rel.Chart.Metadata.AppVersion,
+ },
+ },
+ Labels: rel.Labels,
+ }, nil
+}
+
+// initRollbackClient initializes the rollback client with the given options
+// and returns the rollback client.
+func initRollbackClient(actionConfig *action.Configuration, rollbackOpts options.RollbackOptions) *action.Rollback {
+ rollbackClient := action.NewRollback(actionConfig)
+
+ // Set version to rollback to (if specified)
+ if rollbackOpts.Version > 0 {
+ rollbackClient.Version = rollbackOpts.Version
+ }
+
+ rollbackClient.Wait = rollbackOpts.Wait
+ rollbackClient.WaitForJobs = rollbackOpts.WaitForJobs
+ rollbackClient.CleanupOnFail = true // Sane default to clean up on failure
+ rollbackClient.Recreate = rollbackOpts.Recreate
+ rollbackClient.Force = rollbackOpts.Force
+
+ // Set default values if not specified
+ if rollbackOpts.Timeout == 0 {
+ rollbackClient.Timeout = 5 * time.Minute // Sane default of 5 minutes
+ } else {
+ rollbackClient.Timeout = rollbackOpts.Timeout
+ }
+
+ return rollbackClient
+}
diff --git a/pkg/libhelm/sdk/rollback_test.go b/pkg/libhelm/sdk/rollback_test.go
new file mode 100644
index 000000000..31932ffc7
--- /dev/null
+++ b/pkg/libhelm/sdk/rollback_test.go
@@ -0,0 +1,123 @@
+package sdk
+
+import (
+ "testing"
+
+ "github.com/portainer/portainer/pkg/libhelm/options"
+ "github.com/portainer/portainer/pkg/libhelm/test"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRollback(t *testing.T) {
+ test.EnsureIntegrationTest(t)
+ is := assert.New(t)
+
+ // Create a new SDK package manager
+ hspm := NewHelmSDKPackageManager()
+
+ t.Run("should return error when name is not provided", func(t *testing.T) {
+ rollbackOpts := options.RollbackOptions{
+ Namespace: "default",
+ }
+
+ _, err := hspm.Rollback(rollbackOpts)
+
+ is.Error(err, "should return an error when name is not provided")
+ is.Equal("name is required for helm release rollback", err.Error(), "should return correct error message")
+ })
+
+ t.Run("should return error when release doesn't exist", func(t *testing.T) {
+ rollbackOpts := options.RollbackOptions{
+ Name: "non-existent-release",
+ Namespace: "default",
+ }
+
+ _, err := hspm.Rollback(rollbackOpts)
+
+ is.Error(err, "should return an error when release doesn't exist")
+ })
+
+ t.Run("should successfully rollback to previous revision", func(t *testing.T) {
+ // First install a release
+ installOpts := options.InstallOptions{
+ Name: "hello-world",
+ Chart: "hello-world",
+ Namespace: "default",
+ Repo: "https://helm.github.io/examples",
+ }
+
+ // Ensure the release doesn't exist before test
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ // Install first version
+ release, err := hspm.Upgrade(installOpts)
+ is.NoError(err, "should successfully install release")
+ is.Equal(1, release.Version, "first version should be 1")
+
+ // Upgrade to second version
+ _, err = hspm.Upgrade(installOpts)
+ is.NoError(err, "should successfully upgrade release")
+
+ // Rollback to first version
+ rollbackOpts := options.RollbackOptions{
+ Name: installOpts.Name,
+ Namespace: "default",
+ Version: 0, // Previous revision
+ }
+
+ rolledBackRelease, err := hspm.Rollback(rollbackOpts)
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ is.NoError(err, "should successfully rollback release")
+ is.NotNil(rolledBackRelease, "should return non-nil release")
+ is.Equal(3, rolledBackRelease.Version, "version should be incremented to 3")
+ })
+
+ t.Run("should successfully rollback to specific revision", func(t *testing.T) {
+ // First install a release
+ installOpts := options.InstallOptions{
+ Name: "hello-world",
+ Chart: "hello-world",
+ Namespace: "default",
+ Repo: "https://helm.github.io/examples",
+ }
+
+ // Ensure the release doesn't exist before test
+ hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ // Install first version
+ release, err := hspm.Upgrade(installOpts)
+ is.NoError(err, "should successfully install release")
+ is.Equal(1, release.Version, "first version should be 1")
+
+ // Upgrade to second version
+ _, err = hspm.Upgrade(installOpts)
+ is.NoError(err, "should successfully upgrade release")
+
+ // Upgrade to third version
+ _, err = hspm.Upgrade(installOpts)
+ is.NoError(err, "should successfully upgrade release again")
+
+ // Rollback to first version
+ rollbackOpts := options.RollbackOptions{
+ Name: installOpts.Name,
+ Namespace: "default",
+ Version: 1, // Specific revision
+ }
+
+ rolledBackRelease, err := hspm.Rollback(rollbackOpts)
+ defer hspm.Uninstall(options.UninstallOptions{
+ Name: installOpts.Name,
+ })
+
+ is.NoError(err, "should successfully rollback to specific revision")
+ is.NotNil(rolledBackRelease, "should return non-nil release")
+ is.Equal(4, rolledBackRelease.Version, "version should be incremented to 4")
+ })
+}
diff --git a/pkg/libhelm/sdk/search_repo_test.go b/pkg/libhelm/sdk/search_repo_test.go
index d4cfd9b46..bb30b8468 100644
--- a/pkg/libhelm/sdk/search_repo_test.go
+++ b/pkg/libhelm/sdk/search_repo_test.go
@@ -19,7 +19,6 @@ var tests = []testCase{
{"ingress helm repo", "https://kubernetes.github.io/ingress-nginx", false},
{"portainer helm repo", "https://portainer.github.io/k8s/", false},
{"elastic helm repo with trailing slash", "https://helm.elastic.co/", false},
- {"lensesio helm repo without trailing slash", "https://lensesio.github.io/kafka-helm-charts", false},
}
func Test_SearchRepo(t *testing.T) {
diff --git a/pkg/libhelm/test/mock.go b/pkg/libhelm/test/mock.go
index 518b53fbd..cf8eefd17 100644
--- a/pkg/libhelm/test/mock.go
+++ b/pkg/libhelm/test/mock.go
@@ -79,6 +79,11 @@ func (hpm *helmMockPackageManager) Upgrade(upgradeOpts options.InstallOptions) (
return hpm.Install(upgradeOpts)
}
+// Rollback a helm chart (not thread safe)
+func (hpm *helmMockPackageManager) Rollback(rollbackOpts options.RollbackOptions) (*release.Release, error) {
+ return hpm.Rollback(rollbackOpts)
+}
+
// Show values/readme/chart etc
func (hpm *helmMockPackageManager) Show(showOpts options.ShowOptions) ([]byte, error) {
switch showOpts.OutputFormat {
diff --git a/pkg/libhelm/types/types.go b/pkg/libhelm/types/types.go
index 7c78cbe88..4204dbb3d 100644
--- a/pkg/libhelm/types/types.go
+++ b/pkg/libhelm/types/types.go
@@ -16,6 +16,7 @@ type HelmPackageManager interface {
Uninstall(uninstallOpts options.UninstallOptions) error
Get(getOpts options.GetOptions) (*release.Release, error)
GetHistory(historyOpts options.HistoryOptions) ([]*release.Release, error)
+ Rollback(rollbackOpts options.RollbackOptions) (*release.Release, error)
}
type Repository interface {
From f25d31b92b36c14fc93cf3cbb47abef0a49db01c Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Tue, 22 Apr 2025 18:09:36 -0300
Subject: [PATCH 106/212] fix(code): remove dead code and reduce duplication
BE-11826 (#680)
---
api/archive/zip.go | 55 +++------------------------------
api/cli/pairlistbool.go | 45 ---------------------------
api/docker/client/client.go | 13 --------
api/docker/images/digest.go | 11 +++----
api/docker/images/image.go | 21 ++++++++-----
api/docker/images/image_test.go | 14 ++++-----
api/docker/images/registry.go | 4 +--
api/kubernetes/cli/client.go | 7 -----
api/stacks/stackutils/util.go | 5 ---
9 files changed, 33 insertions(+), 142 deletions(-)
delete mode 100644 api/cli/pairlistbool.go
diff --git a/api/archive/zip.go b/api/archive/zip.go
index 50328ef09..b7dbb9302 100644
--- a/api/archive/zip.go
+++ b/api/archive/zip.go
@@ -2,7 +2,6 @@ package archive
import (
"archive/zip"
- "bytes"
"fmt"
"io"
"os"
@@ -12,50 +11,6 @@ import (
"github.com/pkg/errors"
)
-// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk
-func UnzipArchive(archiveData []byte, dest string) error {
- zipReader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
- if err != nil {
- return err
- }
-
- for _, zipFile := range zipReader.File {
- err := extractFileFromArchive(zipFile, dest)
- if err != nil {
- return err
- }
- }
-
- return nil
-}
-
-func extractFileFromArchive(file *zip.File, dest string) error {
- f, err := file.Open()
- if err != nil {
- return err
- }
- defer f.Close()
-
- data, err := io.ReadAll(f)
- if err != nil {
- return err
- }
-
- fpath := filepath.Join(dest, file.Name)
-
- outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
- if err != nil {
- return err
- }
-
- _, err = io.Copy(outFile, bytes.NewReader(data))
- if err != nil {
- return err
- }
-
- return outFile.Close()
-}
-
// UnzipFile will decompress a zip archive, moving all files and folders
// within the zip file (parameter 1) to an output directory (parameter 2).
func UnzipFile(src string, dest string) error {
@@ -76,11 +31,11 @@ func UnzipFile(src string, dest string) error {
if f.FileInfo().IsDir() {
// Make Folder
os.MkdirAll(p, os.ModePerm)
+
continue
}
- err = unzipFile(f, p)
- if err != nil {
+ if err := unzipFile(f, p); err != nil {
return err
}
}
@@ -93,20 +48,20 @@ func unzipFile(f *zip.File, p string) error {
if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil {
return errors.Wrapf(err, "unzipFile: can't make a path %s", p)
}
+
outFile, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
}
defer outFile.Close()
+
rc, err := f.Open()
if err != nil {
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
}
defer rc.Close()
- _, err = io.Copy(outFile, rc)
-
- if err != nil {
+ if _, err = io.Copy(outFile, rc); err != nil {
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
}
diff --git a/api/cli/pairlistbool.go b/api/cli/pairlistbool.go
deleted file mode 100644
index 69e89b792..000000000
--- a/api/cli/pairlistbool.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package cli
-
-import (
- "strings"
-
- portainer "github.com/portainer/portainer/api"
-
- "gopkg.in/alecthomas/kingpin.v2"
-)
-
-type pairListBool []portainer.Pair
-
-// Set implementation for a list of portainer.Pair
-func (l *pairListBool) Set(value string) error {
- p := new(portainer.Pair)
-
- // default to true. example setting=true is equivalent to setting
- parts := strings.SplitN(value, "=", 2)
- if len(parts) != 2 {
- p.Name = parts[0]
- p.Value = "true"
- } else {
- p.Name = parts[0]
- p.Value = parts[1]
- }
-
- *l = append(*l, *p)
- return nil
-}
-
-// String implementation for a list of pair
-func (l *pairListBool) String() string {
- return ""
-}
-
-// IsCumulative implementation for a list of pair
-func (l *pairListBool) IsCumulative() bool {
- return true
-}
-
-func BoolPairs(s kingpin.Settings) (target *[]portainer.Pair) {
- target = new([]portainer.Pair)
- s.SetValue((*pairListBool)(target))
- return
-}
diff --git a/api/docker/client/client.go b/api/docker/client/client.go
index 1161c4995..d1a2a394a 100644
--- a/api/docker/client/client.go
+++ b/api/docker/client/client.go
@@ -73,19 +73,6 @@ func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
)
}
-func CreateClientFromEnv() (*client.Client, error) {
- return client.NewClientWithOpts(
- client.FromEnv,
- client.WithAPIVersionNegotiation(),
- )
-}
-
-func CreateSimpleClient() (*client.Client, error) {
- return client.NewClientWithOpts(
- client.WithAPIVersionNegotiation(),
- )
-}
-
func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) {
httpCli, err := httpClient(endpoint, timeout)
if err != nil {
diff --git a/api/docker/images/digest.go b/api/docker/images/digest.go
index 591f94d1e..38638de56 100644
--- a/api/docker/images/digest.go
+++ b/api/docker/images/digest.go
@@ -38,10 +38,10 @@ func NewClientWithRegistry(registryClient *RegistryClient, clientFactory *docker
func (c *DigestClient) RemoteDigest(image Image) (digest.Digest, error) {
ctx, cancel := c.timeoutContext()
defer cancel()
+
// Docker references with both a tag and digest are currently not supported
if image.Tag != "" && image.Digest != "" {
- err := image.trimDigest()
- if err != nil {
+ if err := image.TrimDigest(); err != nil {
return "", err
}
}
@@ -69,7 +69,7 @@ func (c *DigestClient) RemoteDigest(image Image) (digest.Digest, error) {
// Retrieve remote digest through HEAD request
rmDigest, err := docker.GetDigest(ctx, sysCtx, rmRef)
if err != nil {
- // fallback to public registry for hub
+ // Fallback to public registry for hub
if image.HubLink != "" {
rmDigest, err = docker.GetDigest(ctx, c.sysCtx, rmRef)
if err == nil {
@@ -131,8 +131,7 @@ func ParseRepoDigests(repoDigests []string) []digest.Digest {
func ParseRepoTags(repoTags []string) []*Image {
images := make([]*Image, 0)
for _, repoTag := range repoTags {
- image := ParseRepoTag(repoTag)
- if image != nil {
+ if image := ParseRepoTag(repoTag); image != nil {
images = append(images, image)
}
}
@@ -147,7 +146,7 @@ func ParseRepoDigest(repoDigest string) digest.Digest {
d, err := digest.Parse(strings.Split(repoDigest, "@")[1])
if err != nil {
- log.Warn().Msgf("Skip invalid repo digest item: %s [error: %v]", repoDigest, err)
+ log.Warn().Err(err).Str("digest", repoDigest).Msg("skip invalid repo item")
return ""
}
diff --git a/api/docker/images/image.go b/api/docker/images/image.go
index 55e72aff0..6b0c6eb81 100644
--- a/api/docker/images/image.go
+++ b/api/docker/images/image.go
@@ -26,7 +26,7 @@ type Image struct {
Digest digest.Digest
HubLink string
named reference.Named
- opts ParseImageOptions
+ Opts ParseImageOptions `json:"-"`
}
// ParseImageOptions holds image options for parsing.
@@ -43,9 +43,10 @@ func (i *Image) Name() string {
// FullName return the real full name may include Tag or Digest of the image, Tag first.
func (i *Image) FullName() string {
if i.Tag == "" {
- return fmt.Sprintf("%s@%s", i.Name(), i.Digest)
+ return i.Name() + "@" + i.Digest.String()
}
- return fmt.Sprintf("%s:%s", i.Name(), i.Tag)
+
+ return i.Name() + ":" + i.Tag
}
// String returns the string representation of an image, including Tag and Digest if existed.
@@ -66,22 +67,25 @@ func (i *Image) Reference() string {
func (i *Image) WithDigest(digest digest.Digest) (err error) {
i.Digest = digest
i.named, err = reference.WithDigest(i.named, digest)
+
return err
}
func (i *Image) WithTag(tag string) (err error) {
i.Tag = tag
i.named, err = reference.WithTag(i.named, tag)
+
return err
}
-func (i *Image) trimDigest() error {
+func (i *Image) TrimDigest() error {
i.Digest = ""
named, err := ParseImage(ParseImageOptions{Name: i.FullName()})
if err != nil {
return err
}
i.named = &named
+
return nil
}
@@ -92,11 +96,12 @@ func ParseImage(parseOpts ParseImageOptions) (Image, error) {
if err != nil {
return Image{}, errors.Wrapf(err, "parsing image %s failed", parseOpts.Name)
}
+
// Add the latest lag if they did not provide one.
named = reference.TagNameOnly(named)
i := Image{
- opts: parseOpts,
+ Opts: parseOpts,
named: named,
Domain: reference.Domain(named),
Path: reference.Path(named),
@@ -122,15 +127,16 @@ func ParseImage(parseOpts ParseImageOptions) (Image, error) {
}
func (i *Image) hubLink() (string, error) {
- if i.opts.HubTpl != "" {
+ if i.Opts.HubTpl != "" {
var out bytes.Buffer
tmpl, err := template.New("tmpl").
Option("missingkey=error").
- Parse(i.opts.HubTpl)
+ Parse(i.Opts.HubTpl)
if err != nil {
return "", err
}
err = tmpl.Execute(&out, i)
+
return out.String(), err
}
@@ -142,6 +148,7 @@ func (i *Image) hubLink() (string, error) {
prefix = "_"
path = strings.Replace(i.Path, "library/", "", 1)
}
+
return "https://hub.docker.com/" + prefix + "/" + path, nil
case "docker.bintray.io", "jfrog-docker-reg2.bintray.io":
return "https://bintray.com/jfrog/reg2/" + strings.ReplaceAll(i.Path, "/", "%3A"), nil
diff --git a/api/docker/images/image_test.go b/api/docker/images/image_test.go
index 2649e387c..713a67732 100644
--- a/api/docker/images/image_test.go
+++ b/api/docker/images/image_test.go
@@ -16,7 +16,7 @@ func TestImageParser(t *testing.T) {
})
is.NoError(err, "")
is.Equal("docker.io/portainer/portainer-ee:latest", image.FullName())
- is.Equal("portainer/portainer-ee", image.opts.Name)
+ is.Equal("portainer/portainer-ee", image.Opts.Name)
is.Equal("latest", image.Tag)
is.Equal("portainer/portainer-ee", image.Path)
is.Equal("docker.io", image.Domain)
@@ -32,7 +32,7 @@ func TestImageParser(t *testing.T) {
})
is.NoError(err, "")
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.FullName())
- is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
+ is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("", image.Tag)
is.Equal("k8s-minikube/kicbase", image.Path)
is.Equal("gcr.io", image.Domain)
@@ -49,7 +49,7 @@ func TestImageParser(t *testing.T) {
})
is.NoError(err, "")
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
- is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
+ is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("v0.0.30", image.Tag)
is.Equal("k8s-minikube/kicbase", image.Path)
is.Equal("gcr.io", image.Domain)
@@ -71,7 +71,7 @@ func TestUpdateParsedImage(t *testing.T) {
is.NoError(err, "")
_ = image.WithTag("v0.0.31")
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.31", image.FullName())
- is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
+ is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("v0.0.31", image.Tag)
is.Equal("k8s-minikube/kicbase", image.Path)
is.Equal("gcr.io", image.Domain)
@@ -89,7 +89,7 @@ func TestUpdateParsedImage(t *testing.T) {
is.NoError(err, "")
_ = image.WithDigest("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3")
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
- is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
+ is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("v0.0.30", image.Tag)
is.Equal("k8s-minikube/kicbase", image.Path)
is.Equal("gcr.io", image.Domain)
@@ -105,9 +105,9 @@ func TestUpdateParsedImage(t *testing.T) {
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
})
is.NoError(err, "")
- _ = image.trimDigest()
+ _ = image.TrimDigest()
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
- is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
+ is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
is.Equal("v0.0.30", image.Tag)
is.Equal("k8s-minikube/kicbase", image.Path)
is.Equal("gcr.io", image.Domain)
diff --git a/api/docker/images/registry.go b/api/docker/images/registry.go
index 10b4a6388..015385c2f 100644
--- a/api/docker/images/registry.go
+++ b/api/docker/images/registry.go
@@ -29,7 +29,7 @@ func (c *RegistryClient) RegistryAuth(image Image) (string, string, error) {
return "", "", err
}
- registry, err := findBestMatchRegistry(image.opts.Name, registries)
+ registry, err := findBestMatchRegistry(image.Opts.Name, registries)
if err != nil {
return "", "", err
}
@@ -59,7 +59,7 @@ func (c *RegistryClient) EncodedRegistryAuth(image Image) (string, error) {
return "", err
}
- registry, err := findBestMatchRegistry(image.opts.Name, registries)
+ registry, err := findBestMatchRegistry(image.Opts.Name, registries)
if err != nil {
return "", err
}
diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go
index ce76f725f..6d2cc437c 100644
--- a/api/kubernetes/cli/client.go
+++ b/api/kubernetes/cli/client.go
@@ -47,13 +47,6 @@ type (
}
)
-func NewKubeClientFromClientset(cli *kubernetes.Clientset) *KubeClient {
- return &KubeClient{
- cli: cli,
- instanceID: "",
- }
-}
-
// NewClientFactory returns a new instance of a ClientFactory
func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, dataStore dataservices.DataStore, instanceID, addrHTTPS, userSessionTimeout string) (*ClientFactory, error) {
if userSessionTimeout == "" {
diff --git a/api/stacks/stackutils/util.go b/api/stacks/stackutils/util.go
index 5dd5bf4c5..9a4daeae3 100644
--- a/api/stacks/stackutils/util.go
+++ b/api/stacks/stackutils/util.go
@@ -45,11 +45,6 @@ func SanitizeLabel(value string) string {
return strings.Trim(onlyAllowedCharacterString, ".-_")
}
-// IsGitStack checks if the stack is a git stack or not
-func IsGitStack(stack *portainer.Stack) bool {
- return stack.GitConfig != nil && len(stack.GitConfig.URL) != 0
-}
-
// IsRelativePathStack checks if the stack is a git stack or not
func IsRelativePathStack(stack *portainer.Stack) bool {
// Always return false in CE
From 3edacee59b015522406cfff5f64845b5c7a5bbb2 Mon Sep 17 00:00:00 2001
From: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Date: Wed, 23 Apr 2025 02:35:20 +0100
Subject: [PATCH 107/212] Update bug report template for 2.29.1 (#682)
---
.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 58dc03d91..6d8f816ea 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.29.1'
- '2.29.0'
- '2.28.1'
- '2.28.0'
From 1a3df54c046e41ccbbc3af374baf56feb52a16fc Mon Sep 17 00:00:00 2001
From: Devon Steenberg
Date: Wed, 23 Apr 2025 13:59:51 +1200
Subject: [PATCH 108/212] fix(govalidator): replace govalidator dependency
[BE-11574] (#673)
---
api/git/update/validate.go | 4 +-
api/git/validate.go | 6 +-
.../customtemplates/customtemplate_create.go | 4 +-
.../customtemplates/customtemplate_update.go | 5 +-
api/http/handler/edgejobs/edgejob_create.go | 7 +-
api/http/handler/edgejobs/edgejob_update.go | 5 +-
.../edgestacks/edgestack_create_git.go | 4 +-
.../handler/gitops/git_repo_file_preview.go | 5 +-
api/http/handler/settings/settings_update.go | 8 +-
.../handler/stacks/create_compose_stack.go | 4 +-
.../handler/stacks/create_kubernetes_stack.go | 6 +-
api/http/handler/stacks/create_swarm_stack.go | 4 +-
.../handler/users/user_create_access_token.go | 7 +-
api/http/handler/websocket/attach.go | 4 +-
api/http/handler/websocket/exec.go | 4 +-
go.mod | 2 +-
pkg/validate/validate.go | 111 +++++
pkg/validate/validate_test.go | 424 ++++++++++++++++++
18 files changed, 571 insertions(+), 43 deletions(-)
create mode 100644 pkg/validate/validate.go
create mode 100644 pkg/validate/validate_test.go
diff --git a/api/git/update/validate.go b/api/git/update/validate.go
index c1b7364ce..66805895d 100644
--- a/api/git/update/validate.go
+++ b/api/git/update/validate.go
@@ -3,9 +3,9 @@ package update
import (
"time"
- "github.com/asaskevich/govalidator"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
+ "github.com/portainer/portainer/pkg/validate"
)
func ValidateAutoUpdateSettings(autoUpdate *portainer.AutoUpdateSettings) error {
@@ -17,7 +17,7 @@ func ValidateAutoUpdateSettings(autoUpdate *portainer.AutoUpdateSettings) error
return httperrors.NewInvalidPayloadError("Webhook or Interval must be provided")
}
- if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) {
+ if autoUpdate.Webhook != "" && !validate.IsUUID(autoUpdate.Webhook) {
return httperrors.NewInvalidPayloadError("invalid Webhook format")
}
diff --git a/api/git/validate.go b/api/git/validate.go
index 8fc04abf6..1304494dd 100644
--- a/api/git/validate.go
+++ b/api/git/validate.go
@@ -1,19 +1,17 @@
package git
import (
- "github.com/asaskevich/govalidator"
-
gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors"
+ "github.com/portainer/portainer/pkg/validate"
)
func ValidateRepoConfig(repoConfig *gittypes.RepoConfig) error {
- if len(repoConfig.URL) == 0 || !govalidator.IsURL(repoConfig.URL) {
+ if len(repoConfig.URL) == 0 || !validate.IsURL(repoConfig.URL) {
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
}
return ValidateRepoAuthentication(repoConfig.Authentication)
-
}
func ValidateRepoAuthentication(auth *gittypes.GitAuthentication) error {
diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go
index 0f9c8f1a3..104ea90a4 100644
--- a/api/http/handler/customtemplates/customtemplate_create.go
+++ b/api/http/handler/customtemplates/customtemplate_create.go
@@ -16,8 +16,8 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
+ "github.com/portainer/portainer/pkg/validate"
- "github.com/asaskevich/govalidator"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
@@ -228,7 +228,7 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request)
if len(payload.Description) == 0 {
return errors.New("Invalid custom template description")
}
- if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
+ if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) {
diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go
index 80c42b6fa..f14d228f3 100644
--- a/api/http/handler/customtemplates/customtemplate_update.go
+++ b/api/http/handler/customtemplates/customtemplate_update.go
@@ -15,8 +15,7 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
-
- "github.com/asaskevich/govalidator"
+ "github.com/portainer/portainer/pkg/validate"
)
type customTemplateUpdatePayload struct {
@@ -170,7 +169,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
customTemplate.EdgeTemplate = payload.EdgeTemplate
if payload.RepositoryURL != "" {
- if !govalidator.IsURL(payload.RepositoryURL) {
+ if !validate.IsURL(payload.RepositoryURL) {
return httperror.BadRequest("Invalid repository URL. Must correspond to a valid URL format", err)
}
diff --git a/api/http/handler/edgejobs/edgejob_create.go b/api/http/handler/edgejobs/edgejob_create.go
index 0f7bff73d..dd6b4d5df 100644
--- a/api/http/handler/edgejobs/edgejob_create.go
+++ b/api/http/handler/edgejobs/edgejob_create.go
@@ -15,8 +15,7 @@ import (
"github.com/portainer/portainer/api/internal/endpointutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
-
- "github.com/asaskevich/govalidator"
+ "github.com/portainer/portainer/pkg/validate"
)
type edgeJobBasePayload struct {
@@ -53,7 +52,7 @@ func (payload *edgeJobCreateFromFileContentPayload) Validate(r *http.Request) er
return errors.New("invalid Edge job name")
}
- if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`) {
+ if !validate.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`) {
return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
@@ -136,7 +135,7 @@ func (payload *edgeJobCreateFromFilePayload) Validate(r *http.Request) error {
return errors.New("invalid Edge job name")
}
- if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
+ if !validate.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
payload.Name = name
diff --git a/api/http/handler/edgejobs/edgejob_update.go b/api/http/handler/edgejobs/edgejob_update.go
index 468fbb8b7..6f2b8e382 100644
--- a/api/http/handler/edgejobs/edgejob_update.go
+++ b/api/http/handler/edgejobs/edgejob_update.go
@@ -14,8 +14,7 @@ import (
"github.com/portainer/portainer/api/internal/endpointutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
-
- "github.com/asaskevich/govalidator"
+ "github.com/portainer/portainer/pkg/validate"
)
type edgeJobUpdatePayload struct {
@@ -28,7 +27,7 @@ type edgeJobUpdatePayload struct {
}
func (payload *edgeJobUpdatePayload) Validate(r *http.Request) error {
- if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
+ if payload.Name != nil && !validate.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
diff --git a/api/http/handler/edgestacks/edgestack_create_git.go b/api/http/handler/edgestacks/edgestack_create_git.go
index 88e4bda79..2da816481 100644
--- a/api/http/handler/edgestacks/edgestack_create_git.go
+++ b/api/http/handler/edgestacks/edgestack_create_git.go
@@ -11,8 +11,8 @@ import (
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
+ "github.com/portainer/portainer/pkg/validate"
- "github.com/asaskevich/govalidator"
"github.com/pkg/errors"
)
@@ -59,7 +59,7 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
}
- if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
+ if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
}
diff --git a/api/http/handler/gitops/git_repo_file_preview.go b/api/http/handler/gitops/git_repo_file_preview.go
index 28e8fafec..1eaa52716 100644
--- a/api/http/handler/gitops/git_repo_file_preview.go
+++ b/api/http/handler/gitops/git_repo_file_preview.go
@@ -9,8 +9,7 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
-
- "github.com/asaskevich/govalidator"
+ "github.com/portainer/portainer/pkg/validate"
)
type fileResponse struct {
@@ -29,7 +28,7 @@ type repositoryFilePreviewPayload struct {
}
func (payload *repositoryFilePreviewPayload) Validate(r *http.Request) error {
- if len(payload.Repository) == 0 || !govalidator.IsURL(payload.Repository) {
+ if len(payload.Repository) == 0 || !validate.IsURL(payload.Repository) {
return errors.New("invalid repository URL. Must correspond to a valid URL format")
}
diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go
index 0b36dbc62..98da8da7d 100644
--- a/api/http/handler/settings/settings_update.go
+++ b/api/http/handler/settings/settings_update.go
@@ -14,8 +14,8 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
+ "github.com/portainer/portainer/pkg/validate"
- "github.com/asaskevich/govalidator"
"github.com/pkg/errors"
"golang.org/x/oauth2"
)
@@ -62,15 +62,15 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
return errors.New("Invalid authentication method value. Value must be one of: 1 (internal), 2 (LDAP/AD) or 3 (OAuth)")
}
- if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) {
+ if payload.LogoURL != nil && *payload.LogoURL != "" && !validate.IsURL(*payload.LogoURL) {
return errors.New("Invalid logo URL. Must correspond to a valid URL format")
}
- if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !govalidator.IsURL(*payload.TemplatesURL) {
+ if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !validate.IsURL(*payload.TemplatesURL) {
return errors.New("Invalid external templates URL. Must correspond to a valid URL format")
}
- if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" && !govalidator.IsURL(*payload.HelmRepositoryURL) {
+ if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" && !validate.IsURL(*payload.HelmRepositoryURL) {
return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format")
}
diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go
index dce39337a..fc5bed1ff 100644
--- a/api/http/handler/stacks/create_compose_stack.go
+++ b/api/http/handler/stacks/create_compose_stack.go
@@ -14,8 +14,8 @@ import (
"github.com/portainer/portainer/api/stacks/stackutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
+ "github.com/portainer/portainer/pkg/validate"
- "github.com/asaskevich/govalidator"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
@@ -205,7 +205,7 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
if len(payload.Name) == 0 {
return errors.New("Invalid stack name")
}
- if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
+ if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go
index 397ccfec2..f1a142e6b 100644
--- a/api/http/handler/stacks/create_kubernetes_stack.go
+++ b/api/http/handler/stacks/create_kubernetes_stack.go
@@ -15,8 +15,8 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
+ "github.com/portainer/portainer/pkg/validate"
- "github.com/asaskevich/govalidator"
"github.com/pkg/errors"
)
@@ -96,7 +96,7 @@ func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) erro
}
func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
- if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
+ if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
@@ -112,7 +112,7 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
}
func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request) error {
- if len(payload.ManifestURL) == 0 || !govalidator.IsURL(payload.ManifestURL) {
+ if len(payload.ManifestURL) == 0 || !validate.IsURL(payload.ManifestURL) {
return errors.New("Invalid manifest URL")
}
diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go
index 4603b6d6b..e10d23f2f 100644
--- a/api/http/handler/stacks/create_swarm_stack.go
+++ b/api/http/handler/stacks/create_swarm_stack.go
@@ -11,8 +11,8 @@ import (
"github.com/portainer/portainer/api/stacks/stackutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
+ valid "github.com/portainer/portainer/pkg/validate"
- "github.com/asaskevich/govalidator"
"github.com/pkg/errors"
)
@@ -142,7 +142,7 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
if len(payload.SwarmID) == 0 {
return errors.New("Invalid Swarm ID")
}
- if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
+ if len(payload.RepositoryURL) == 0 || !valid.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
diff --git a/api/http/handler/users/user_create_access_token.go b/api/http/handler/users/user_create_access_token.go
index 673c0af76..aa10f6fe0 100644
--- a/api/http/handler/users/user_create_access_token.go
+++ b/api/http/handler/users/user_create_access_token.go
@@ -11,8 +11,7 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
-
- "github.com/asaskevich/govalidator"
+ "github.com/portainer/portainer/pkg/validate"
)
type userAccessTokenCreatePayload struct {
@@ -24,10 +23,10 @@ func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
if len(payload.Description) == 0 {
return errors.New("invalid description: cannot be empty")
}
- if govalidator.HasWhitespaceOnly(payload.Description) {
+ if validate.HasWhitespaceOnly(payload.Description) {
return errors.New("invalid description: cannot contain only whitespaces")
}
- if govalidator.MinStringLength(payload.Description, "128") {
+ if validate.MinStringLength(payload.Description, 128) {
return errors.New("invalid description: cannot be longer than 128 characters")
}
return nil
diff --git a/api/http/handler/websocket/attach.go b/api/http/handler/websocket/attach.go
index d0cb7746f..96e228418 100644
--- a/api/http/handler/websocket/attach.go
+++ b/api/http/handler/websocket/attach.go
@@ -9,8 +9,8 @@ import (
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
+ "github.com/portainer/portainer/pkg/validate"
- "github.com/asaskevich/govalidator"
"github.com/gorilla/websocket"
)
@@ -38,7 +38,7 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request)
if err != nil {
return httperror.BadRequest("Invalid query parameter: id", err)
}
- if !govalidator.IsHexadecimal(attachID) {
+ if !validate.IsHexadecimal(attachID) {
return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err)
}
diff --git a/api/http/handler/websocket/exec.go b/api/http/handler/websocket/exec.go
index ab04b0702..aef5861c8 100644
--- a/api/http/handler/websocket/exec.go
+++ b/api/http/handler/websocket/exec.go
@@ -8,8 +8,8 @@ import (
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
+ "github.com/portainer/portainer/pkg/validate"
- "github.com/asaskevich/govalidator"
"github.com/gorilla/websocket"
"github.com/segmentio/encoding/json"
)
@@ -42,7 +42,7 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h
if err != nil {
return httperror.BadRequest("Invalid query parameter: id", err)
}
- if !govalidator.IsHexadecimal(execID) {
+ if !validate.IsHexadecimal(execID) {
return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err)
}
diff --git a/go.mod b/go.mod
index 64c594902..45f0eb750 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,6 @@ require (
github.com/Masterminds/semver v1.5.0
github.com/Microsoft/go-winio v0.6.2
github.com/VictoriaMetrics/fastcache v1.12.0
- github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
github.com/aws/aws-sdk-go-v2 v1.24.1
github.com/aws/aws-sdk-go-v2/credentials v1.16.16
github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1
@@ -85,6 +84,7 @@ require (
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2 // indirect
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 // indirect
+ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/config v1.26.6 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect
diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go
new file mode 100644
index 000000000..3683941cf
--- /dev/null
+++ b/pkg/validate/validate.go
@@ -0,0 +1,111 @@
+package validate
+
+import (
+ "net"
+ "net/url"
+ "regexp"
+ "strings"
+ "unicode/utf8"
+)
+
+const (
+ minURLRuneCount = 3
+ maxURLRuneCount = 2083
+
+ ipPattern = `(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))`
+ urlSchemaPattern = `((ftp|tcp|udp|wss?|https?):\/\/)`
+ urlUsernamePattern = `(\S+(:\S*)?@)`
+ urlPathPattern = `((\/|\?|#)[^\s]*)`
+ urlPortPattern = `(:(\d{1,5}))`
+ urlIPPattern = `([1-9]\d?|1\d\d|2[01]\d|22[0-3]|24\d|25[0-5])(\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])){2}(?:\.([0-9]\d?|1\d\d|2[0-4]\d|25[0-5]))`
+ urlSubdomainPattern = `((www\.)|([a-zA-Z0-9]+([-_\.]?[a-zA-Z0-9])*[a-zA-Z0-9]\.[a-zA-Z0-9]+))`
+ urlPattern = `^` + urlSchemaPattern + `?` + urlUsernamePattern + `?` + `((` + urlIPPattern + `|(\[` + ipPattern + `\])|(([a-zA-Z0-9]([a-zA-Z0-9-_]+)?[a-zA-Z0-9]([-\.][a-zA-Z0-9]+)*)|(` + urlSubdomainPattern + `?))?(([a-zA-Z\x{00a1}-\x{ffff}0-9]+-?-?)*[a-zA-Z\x{00a1}-\x{ffff}0-9]+)(?:\.([a-zA-Z\x{00a1}-\x{ffff}]{1,}))?))\.?` + urlPortPattern + `?` + urlPathPattern + `?$`
+)
+
+var (
+ urlRegex = regexp.MustCompile(urlPattern)
+ uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
+ hexadecimalRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
+ whitespaceRegex = regexp.MustCompile(`^[[:space:]]+$`)
+ dnsNameRegex = regexp.MustCompile(`^([a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62}){1}(\.[a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62})*[\._]?$`)
+)
+
+func IsURL(urlString string) bool {
+ if urlString == "" ||
+ utf8.RuneCountInString(urlString) >= maxURLRuneCount ||
+ len(urlString) <= minURLRuneCount ||
+ strings.HasPrefix(urlString, ".") {
+ return false
+ }
+
+ strTemp := urlString
+ if strings.Contains(urlString, ":") && !strings.Contains(urlString, "://") {
+ // support no indicated urlscheme but with colon for port number
+ // http:// is appended so url.Parse will succeed, strTemp used so it does not impact rxURL.MatchString
+ strTemp = "http://" + urlString
+ }
+
+ u, err := url.Parse(strTemp)
+ if err != nil {
+ return false
+ }
+
+ if strings.HasPrefix(u.Host, ".") {
+ return false
+ }
+ if u.Host == "" && (u.Path != "" && !strings.Contains(u.Path, ".")) {
+ return false
+ }
+
+ return urlRegex.MatchString(urlString)
+}
+
+func IsUUID(uuidString string) bool {
+ return uuidRegex.MatchString(uuidString)
+}
+
+func IsHexadecimal(hexString string) bool {
+ return hexadecimalRegex.MatchString(hexString)
+}
+
+func HasWhitespaceOnly(s string) bool {
+ return len(s) > 0 && whitespaceRegex.MatchString(s)
+}
+
+func MinStringLength(s string, len int) bool {
+ return utf8.RuneCountInString(s) >= len
+}
+
+func Matches(s, pattern string) bool {
+ match, err := regexp.MatchString(pattern, s)
+ return err == nil && match
+}
+
+func IsNonPositive(f float64) bool {
+ return f <= 0
+}
+
+func InRange(val, left, right float64) bool {
+ if left > right {
+ left, right = right, left
+ }
+
+ return val >= left && val <= right
+}
+
+func IsHost(s string) bool {
+ return IsIP(s) || IsDNSName(s)
+}
+
+func IsIP(s string) bool {
+ return net.ParseIP(s) != nil
+}
+
+func IsDNSName(s string) bool {
+ if s == "" || len(strings.ReplaceAll(s, ".", "")) > 255 {
+ // constraints already violated
+ return false
+ }
+
+ return !IsIP(s) && dnsNameRegex.MatchString(s)
+}
diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go
new file mode 100644
index 000000000..e8b62288e
--- /dev/null
+++ b/pkg/validate/validate_test.go
@@ -0,0 +1,424 @@
+package validate
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func Test_IsURL(t *testing.T) {
+ testCases := []struct {
+ name string
+ url string
+ expectedResult bool
+ }{
+ {
+ name: "simple url",
+ url: "https://google.com",
+ expectedResult: true,
+ },
+ {
+ name: "no schema",
+ url: "google.com",
+ expectedResult: true,
+ },
+ {
+ name: "path",
+ url: "https://google.com/some/thing",
+ expectedResult: true,
+ },
+ {
+ name: "query params",
+ url: "https://google.com/some/thing?a=5&b=6",
+ expectedResult: true,
+ },
+ {
+ name: "invalid",
+ url: "google",
+ expectedResult: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := IsURL(tc.url)
+ require.Equal(t, tc.expectedResult, result)
+ })
+ }
+}
+
+func Test_IsUUID(t *testing.T) {
+ testCases := []struct {
+ name string
+ uuid string
+ expectedResult bool
+ }{
+ {
+ name: "empty",
+ uuid: "",
+ expectedResult: false,
+ },
+ {
+ name: "version 3 UUID",
+ uuid: "060507eb-3b9a-362e-b850-d5f065eea403",
+ expectedResult: true,
+ },
+ {
+ name: "version 4 UUID",
+ uuid: "63e695ee-48a9-498a-98b3-9472ff75e09f",
+ expectedResult: true,
+ },
+ {
+ name: "version 5 UUID",
+ uuid: "5daabcd8-f17e-568c-aa6f-da9d92c7032c",
+ expectedResult: true,
+ },
+ {
+ name: "text",
+ uuid: "something like this",
+ expectedResult: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := IsUUID(tc.uuid)
+ require.Equal(t, tc.expectedResult, result)
+ })
+ }
+}
+
+func Test_IsHexadecimal(t *testing.T) {
+ testCases := []struct {
+ name string
+ hex string
+ expectedResult bool
+ }{
+ {
+ name: "empty",
+ hex: "",
+ expectedResult: false,
+ },
+ {
+ name: "hex",
+ hex: "48656C6C6F20736F6D657468696E67",
+ expectedResult: true,
+ },
+ {
+ name: "text",
+ hex: "something like this",
+ expectedResult: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := IsHexadecimal(tc.hex)
+ require.Equal(t, tc.expectedResult, result)
+ })
+ }
+}
+
+func Test_HasWhitespaceOnly(t *testing.T) {
+ testCases := []struct {
+ name string
+ s string
+ expectedResult bool
+ }{
+ {
+ name: "empty",
+ s: "",
+ expectedResult: false,
+ },
+ {
+ name: "space",
+ s: " ",
+ expectedResult: true,
+ },
+ {
+ name: "tab",
+ s: "\t",
+ expectedResult: true,
+ },
+ {
+ name: "text",
+ s: "something like this",
+ expectedResult: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := HasWhitespaceOnly(tc.s)
+ require.Equal(t, tc.expectedResult, result)
+ })
+ }
+}
+
+func Test_MinStringLength(t *testing.T) {
+ testCases := []struct {
+ name string
+ s string
+ len int
+ expectedResult bool
+ }{
+ {
+ name: "empty + zero len",
+ s: "",
+ len: 0,
+ expectedResult: true,
+ },
+ {
+ name: "empty + non zero len",
+ s: "",
+ len: 10,
+ expectedResult: false,
+ },
+ {
+ name: "long text + non zero len",
+ s: "something else",
+ len: 10,
+ expectedResult: true,
+ },
+ {
+ name: "multibyte characters - enough",
+ s: "X生",
+ len: 2,
+ expectedResult: true,
+ },
+ {
+ name: "multibyte characters - not enough",
+ s: "X生",
+ len: 3,
+ expectedResult: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := MinStringLength(tc.s, tc.len)
+ require.Equal(t, tc.expectedResult, result)
+ })
+ }
+}
+
+func Test_Matches(t *testing.T) {
+ testCases := []struct {
+ name string
+ s string
+ pattern string
+ expectedResult bool
+ }{
+ {
+ name: "empty",
+ s: "",
+ pattern: "",
+ expectedResult: true,
+ },
+ {
+ name: "space",
+ s: "something else",
+ pattern: " ",
+ expectedResult: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := Matches(tc.s, tc.pattern)
+ require.Equal(t, tc.expectedResult, result)
+ })
+ }
+}
+
+func Test_IsNonPositive(t *testing.T) {
+ testCases := []struct {
+ name string
+ f float64
+ expectedResult bool
+ }{
+ {
+ name: "zero",
+ f: 0,
+ expectedResult: true,
+ },
+ {
+ name: "positive",
+ f: 1,
+ expectedResult: false,
+ },
+ {
+ name: "negative",
+ f: -1,
+ expectedResult: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := IsNonPositive(tc.f)
+ require.Equal(t, tc.expectedResult, result)
+ })
+ }
+}
+
+func Test_InRange(t *testing.T) {
+ testCases := []struct {
+ name string
+ f float64
+ left float64
+ right float64
+ expectedResult bool
+ }{
+ {
+ name: "zero",
+ f: 0,
+ left: 0,
+ right: 0,
+ expectedResult: true,
+ },
+ {
+ name: "equal left",
+ f: 1,
+ left: 1,
+ right: 2,
+ expectedResult: true,
+ },
+ {
+ name: "equal right",
+ f: 2,
+ left: 1,
+ right: 2,
+ expectedResult: true,
+ },
+ {
+ name: "above",
+ f: 3,
+ left: 1,
+ right: 2,
+ expectedResult: false,
+ },
+ {
+ name: "below",
+ f: 0,
+ left: 1,
+ right: 2,
+ expectedResult: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := InRange(tc.f, tc.left, tc.right)
+ require.Equal(t, tc.expectedResult, result)
+ })
+ }
+}
+
+func Test_IsHost(t *testing.T) {
+ testCases := []struct {
+ name string
+ s string
+ expectedResult bool
+ }{
+ {
+ name: "empty",
+ s: "",
+ expectedResult: false,
+ },
+ {
+ name: "ip address",
+ s: "192.168.1.1",
+ expectedResult: true,
+ },
+ {
+ name: "hostname",
+ s: "google.com",
+ expectedResult: true,
+ },
+ {
+ name: "text",
+ s: "Something like this",
+ expectedResult: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := IsHost(tc.s)
+ require.Equal(t, tc.expectedResult, result)
+ })
+ }
+}
+
+func Test_IsIP(t *testing.T) {
+ testCases := []struct {
+ name string
+ s string
+ expectedResult bool
+ }{
+ {
+ name: "empty",
+ s: "",
+ expectedResult: false,
+ },
+ {
+ name: "ip address",
+ s: "192.168.1.1",
+ expectedResult: true,
+ },
+ {
+ name: "hostname",
+ s: "google.com",
+ expectedResult: false,
+ },
+ {
+ name: "text",
+ s: "Something like this",
+ expectedResult: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := IsIP(tc.s)
+ require.Equal(t, tc.expectedResult, result)
+ })
+ }
+}
+
+func Test_IsDNSName(t *testing.T) {
+ testCases := []struct {
+ name string
+ s string
+ expectedResult bool
+ }{
+ {
+ name: "empty",
+ s: "",
+ expectedResult: false,
+ },
+ {
+ name: "ip address",
+ s: "192.168.1.1",
+ expectedResult: false,
+ },
+ {
+ name: "hostname",
+ s: "google.com",
+ expectedResult: true,
+ },
+ {
+ name: "text",
+ s: "Something like this",
+ expectedResult: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := IsDNSName(tc.s)
+ require.Equal(t, tc.expectedResult, result)
+ })
+ }
+}
From 4e4fd5a4b43f9b665864733bd3ae850bbbd4cfc8 Mon Sep 17 00:00:00 2001
From: Devon Steenberg
Date: Thu, 24 Apr 2025 08:59:44 +1200
Subject: [PATCH 109/212] fix(validate): refactor validate functions [BE-11574]
(#683)
---
pkg/validate/validate.go | 45 +++++++----------------------------
pkg/validate/validate_test.go | 19 +++++++++++++--
2 files changed, 25 insertions(+), 39 deletions(-)
diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go
index 3683941cf..f647d1e17 100644
--- a/pkg/validate/validate.go
+++ b/pkg/validate/validate.go
@@ -6,62 +6,33 @@ import (
"regexp"
"strings"
"unicode/utf8"
-)
-const (
- minURLRuneCount = 3
- maxURLRuneCount = 2083
-
- ipPattern = `(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))`
- urlSchemaPattern = `((ftp|tcp|udp|wss?|https?):\/\/)`
- urlUsernamePattern = `(\S+(:\S*)?@)`
- urlPathPattern = `((\/|\?|#)[^\s]*)`
- urlPortPattern = `(:(\d{1,5}))`
- urlIPPattern = `([1-9]\d?|1\d\d|2[01]\d|22[0-3]|24\d|25[0-5])(\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])){2}(?:\.([0-9]\d?|1\d\d|2[0-4]\d|25[0-5]))`
- urlSubdomainPattern = `((www\.)|([a-zA-Z0-9]+([-_\.]?[a-zA-Z0-9])*[a-zA-Z0-9]\.[a-zA-Z0-9]+))`
- urlPattern = `^` + urlSchemaPattern + `?` + urlUsernamePattern + `?` + `((` + urlIPPattern + `|(\[` + ipPattern + `\])|(([a-zA-Z0-9]([a-zA-Z0-9-_]+)?[a-zA-Z0-9]([-\.][a-zA-Z0-9]+)*)|(` + urlSubdomainPattern + `?))?(([a-zA-Z\x{00a1}-\x{ffff}0-9]+-?-?)*[a-zA-Z\x{00a1}-\x{ffff}0-9]+)(?:\.([a-zA-Z\x{00a1}-\x{ffff}]{1,}))?))\.?` + urlPortPattern + `?` + urlPathPattern + `?$`
+ "github.com/google/uuid"
)
var (
- urlRegex = regexp.MustCompile(urlPattern)
- uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
hexadecimalRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
- whitespaceRegex = regexp.MustCompile(`^[[:space:]]+$`)
dnsNameRegex = regexp.MustCompile(`^([a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62}){1}(\.[a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62})*[\._]?$`)
)
func IsURL(urlString string) bool {
- if urlString == "" ||
- utf8.RuneCountInString(urlString) >= maxURLRuneCount ||
- len(urlString) <= minURLRuneCount ||
- strings.HasPrefix(urlString, ".") {
+ if len(urlString) == 0 {
return false
}
strTemp := urlString
- if strings.Contains(urlString, ":") && !strings.Contains(urlString, "://") {
- // support no indicated urlscheme but with colon for port number
- // http:// is appended so url.Parse will succeed, strTemp used so it does not impact rxURL.MatchString
+ if !strings.Contains(urlString, "://") {
+ // support no indicated urlscheme
+ // http:// is appended so url.Parse will succeed
strTemp = "http://" + urlString
}
u, err := url.Parse(strTemp)
- if err != nil {
- return false
- }
-
- if strings.HasPrefix(u.Host, ".") {
- return false
- }
- if u.Host == "" && (u.Path != "" && !strings.Contains(u.Path, ".")) {
- return false
- }
-
- return urlRegex.MatchString(urlString)
+ return err == nil && u.Host != ""
}
func IsUUID(uuidString string) bool {
- return uuidRegex.MatchString(uuidString)
+ return uuid.Validate(uuidString) == nil
}
func IsHexadecimal(hexString string) bool {
@@ -69,7 +40,7 @@ func IsHexadecimal(hexString string) bool {
}
func HasWhitespaceOnly(s string) bool {
- return len(s) > 0 && whitespaceRegex.MatchString(s)
+ return len(s) > 0 && strings.TrimSpace(s) == ""
}
func MinStringLength(s string, len int) bool {
diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go
index e8b62288e..f3cb6a01c 100644
--- a/pkg/validate/validate_test.go
+++ b/pkg/validate/validate_test.go
@@ -17,6 +17,11 @@ func Test_IsURL(t *testing.T) {
url: "https://google.com",
expectedResult: true,
},
+ {
+ name: "empty",
+ url: "",
+ expectedResult: false,
+ },
{
name: "no schema",
url: "google.com",
@@ -33,9 +38,14 @@ func Test_IsURL(t *testing.T) {
expectedResult: true,
},
{
- name: "invalid",
+ name: "no top level domain",
url: "google",
- expectedResult: false,
+ expectedResult: true,
+ },
+ {
+ name: "Unicode URL",
+ url: "www.xn--exampe-7db.ai",
+ expectedResult: true,
},
}
@@ -145,6 +155,11 @@ func Test_HasWhitespaceOnly(t *testing.T) {
s: "something like this",
expectedResult: false,
},
+ {
+ name: "all whitespace",
+ s: "\t\n\v\f\r ",
+ expectedResult: true,
+ },
}
for _, tc := range testCases {
From 24b3499c707f36462bb9fb727d14002164466387 Mon Sep 17 00:00:00 2001
From: Viktor Pettersson
Date: Thu, 24 Apr 2025 02:13:45 +0200
Subject: [PATCH 110/212] fix(dependencies): downgrade gorilla/csrf to v1.7.2
(#684)
---
go.mod | 2 +-
go.sum | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/go.mod b/go.mod
index 45f0eb750..0432cec7a 100644
--- a/go.mod
+++ b/go.mod
@@ -25,7 +25,7 @@ require (
github.com/gofrs/uuid v4.2.0+incompatible
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.6.0
- github.com/gorilla/csrf v1.7.3
+ github.com/gorilla/csrf v1.7.2
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/go-version v1.7.0
diff --git a/go.sum b/go.sum
index 3f4be510b..eed5e28b8 100644
--- a/go.sum
+++ b/go.sum
@@ -338,8 +338,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
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/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
+github.com/gorilla/csrf v1.7.2/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/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
From 8cc28761d74f68eaf397a5b475aad2c27ad1dae8 Mon Sep 17 00:00:00 2001
From: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Date: Thu, 24 Apr 2025 05:47:31 +0100
Subject: [PATCH 111/212] Update bug report template for 2.29.2 (#692)
---
.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 6d8f816ea..1bafbfd20 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.29.2'
- '2.29.1'
- '2.29.0'
- '2.28.1'
From 4d4360b86b8e53196413d82426c994f01168d243 Mon Sep 17 00:00:00 2001
From: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Date: Fri, 2 May 2025 02:14:39 +0100
Subject: [PATCH 112/212] Update bug report template for 2.27.5 (#705)
---
.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 1bafbfd20..bb0fe9553 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -99,6 +99,7 @@ body:
- '2.29.0'
- '2.28.1'
- '2.28.0'
+ - '2.27.5'
- '2.27.4'
- '2.27.3'
- '2.27.2'
From bc29419c17a77d80ac3801aaf15562a54865ca03 Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Wed, 7 May 2025 20:40:38 +1200
Subject: [PATCH 113/212] refactor: replace the `kubectl` binary with the
upstream sdk (#524)
---
api/cmd/portainer/main.go | 6 +-
api/exec/exectest/kubernetes_mocks.go | 12 ++
api/exec/kubernetes_deploy.go | 71 +++++------
api/exec/kubernetes_deploy_test.go | 173 ++++++++++++++++++++++++++
build/download_binaries.sh | 14 +--
build/download_kubectl_binary.sh | 19 ---
build/linux/Dockerfile | 1 -
build/linux/alpine.Dockerfile | 1 -
build/windows/Dockerfile | 1 -
go.mod | 4 +-
pkg/libkubectl/apply.go | 23 ++++
pkg/libkubectl/client.go | 4 +-
pkg/libkubectl/client_test.go | 101 ---------------
pkg/libkubectl/delete.go | 24 ++++
pkg/libkubectl/manifest.go | 11 ++
pkg/libkubectl/manifest_test.go | 48 +++++++
pkg/libkubectl/restart.go | 23 ++++
17 files changed, 354 insertions(+), 182 deletions(-)
create mode 100644 api/exec/kubernetes_deploy_test.go
delete mode 100755 build/download_kubectl_binary.sh
create mode 100644 pkg/libkubectl/apply.go
delete mode 100644 pkg/libkubectl/client_test.go
create mode 100644 pkg/libkubectl/delete.go
create mode 100644 pkg/libkubectl/manifest.go
create mode 100644 pkg/libkubectl/manifest_test.go
create mode 100644 pkg/libkubectl/restart.go
diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go
index 99f809c7c..6261efbd9 100644
--- a/api/cmd/portainer/main.go
+++ b/api/cmd/portainer/main.go
@@ -167,8 +167,8 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
}
-func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer {
- return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
+func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) portainer.KubernetesDeployer {
+ return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
}
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
@@ -423,7 +423,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
}
- kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, *flags.Assets)
+ kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory)
pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore))
diff --git a/api/exec/exectest/kubernetes_mocks.go b/api/exec/exectest/kubernetes_mocks.go
index 894e52135..7d2afac73 100644
--- a/api/exec/exectest/kubernetes_mocks.go
+++ b/api/exec/exectest/kubernetes_mocks.go
@@ -12,3 +12,15 @@ type kubernetesMockDeployer struct {
func NewKubernetesDeployer() *kubernetesMockDeployer {
return &kubernetesMockDeployer{}
}
+
+func (deployer *kubernetesMockDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
+ return "", nil
+}
+
+func (deployer *kubernetesMockDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
+ return "", nil
+}
+
+func (deployer *kubernetesMockDeployer) Restart(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
+ return "", nil
+}
diff --git a/api/exec/kubernetes_deploy.go b/api/exec/kubernetes_deploy.go
index 1941a6513..0bdd5caa2 100644
--- a/api/exec/kubernetes_deploy.go
+++ b/api/exec/kubernetes_deploy.go
@@ -1,13 +1,8 @@
package exec
import (
- "bytes"
+ "context"
"fmt"
- "os"
- "os/exec"
- "path"
- "runtime"
- "strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -15,13 +10,17 @@ import (
"github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
+ "github.com/portainer/portainer/pkg/libkubectl"
"github.com/pkg/errors"
)
+const (
+ defaultServerURL = "https://kubernetes.default.svc"
+)
+
// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment(endpoint).
type KubernetesDeployer struct {
- binaryPath string
dataStore dataservices.DataStore
reverseTunnelService portainer.ReverseTunnelService
signatureService portainer.DigitalSignatureService
@@ -31,9 +30,8 @@ type KubernetesDeployer struct {
}
// NewKubernetesDeployer initializes a new KubernetesDeployer service.
-func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, binaryPath string) *KubernetesDeployer {
+func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) *KubernetesDeployer {
return &KubernetesDeployer{
- binaryPath: binaryPath,
dataStore: datastore,
reverseTunnelService: reverseTunnelService,
signatureService: signatureService,
@@ -93,48 +91,41 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
return "", errors.Wrap(err, "failed generating a user token")
}
- command := path.Join(deployer.binaryPath, "kubectl")
- if runtime.GOOS == "windows" {
- command = path.Join(deployer.binaryPath, "kubectl.exe")
- }
-
- args := []string{"--token", token}
- if namespace != "" {
- args = append(args, "--namespace", namespace)
- }
-
+ serverURL := defaultServerURL
if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
url, proxy, err := deployer.getAgentURL(endpoint)
if err != nil {
return "", errors.WithMessage(err, "failed generating endpoint URL")
}
-
defer proxy.Close()
- args = append(args, "--server", url)
- args = append(args, "--insecure-skip-tls-verify")
+
+ serverURL = url
}
- if operation == "delete" {
- args = append(args, "--ignore-not-found=true")
- }
-
- args = append(args, operation)
- for _, path := range manifestFiles {
- args = append(args, "-f", strings.TrimSpace(path))
- }
-
- var stderr bytes.Buffer
- cmd := exec.Command(command, args...)
- cmd.Env = os.Environ()
- cmd.Env = append(cmd.Env, "POD_NAMESPACE=default")
- cmd.Stderr = &stderr
-
- output, err := cmd.Output()
+ client, err := libkubectl.NewClient(&libkubectl.ClientAccess{
+ Token: token,
+ ServerUrl: serverURL,
+ }, namespace, "", true)
if err != nil {
- return "", errors.Wrapf(err, "failed to execute kubectl command: %q", stderr.String())
+ return "", errors.Wrap(err, "failed to create kubectl client")
}
- return string(output), nil
+ operations := map[string]func(context.Context, []string) (string, error){
+ "apply": client.Apply,
+ "delete": client.Delete,
+ }
+
+ operationFunc, ok := operations[operation]
+ if !ok {
+ return "", errors.Errorf("unsupported operation: %s", operation)
+ }
+
+ output, err := operationFunc(context.Background(), manifestFiles)
+ if err != nil {
+ return "", errors.Wrapf(err, "failed to execute kubectl %s command", operation)
+ }
+
+ return output, nil
}
func (deployer *KubernetesDeployer) getAgentURL(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
diff --git a/api/exec/kubernetes_deploy_test.go b/api/exec/kubernetes_deploy_test.go
new file mode 100644
index 000000000..cd49a2b92
--- /dev/null
+++ b/api/exec/kubernetes_deploy_test.go
@@ -0,0 +1,173 @@
+package exec
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type mockKubectlClient struct {
+ applyFunc func(ctx context.Context, files []string) error
+ deleteFunc func(ctx context.Context, files []string) error
+ rolloutRestartFunc func(ctx context.Context, resources []string) error
+}
+
+func (m *mockKubectlClient) Apply(ctx context.Context, files []string) error {
+ if m.applyFunc != nil {
+ return m.applyFunc(ctx, files)
+ }
+ return nil
+}
+
+func (m *mockKubectlClient) Delete(ctx context.Context, files []string) error {
+ if m.deleteFunc != nil {
+ return m.deleteFunc(ctx, files)
+ }
+ return nil
+}
+
+func (m *mockKubectlClient) RolloutRestart(ctx context.Context, resources []string) error {
+ if m.rolloutRestartFunc != nil {
+ return m.rolloutRestartFunc(ctx, resources)
+ }
+ return nil
+}
+
+func testExecuteKubectlOperation(client *mockKubectlClient, operation string, manifestFiles []string) error {
+ operations := map[string]func(context.Context, []string) error{
+ "apply": client.Apply,
+ "delete": client.Delete,
+ "rollout-restart": client.RolloutRestart,
+ }
+
+ operationFunc, ok := operations[operation]
+ if !ok {
+ return fmt.Errorf("unsupported operation: %s", operation)
+ }
+
+ if err := operationFunc(context.Background(), manifestFiles); err != nil {
+ return fmt.Errorf("failed to execute kubectl %s command: %w", operation, err)
+ }
+
+ return nil
+}
+
+func TestExecuteKubectlOperation_Apply_Success(t *testing.T) {
+ called := false
+ mockClient := &mockKubectlClient{
+ applyFunc: func(ctx context.Context, files []string) error {
+ called = true
+ assert.Equal(t, []string{"manifest1.yaml", "manifest2.yaml"}, files)
+ return nil
+ },
+ }
+
+ manifests := []string{"manifest1.yaml", "manifest2.yaml"}
+ err := testExecuteKubectlOperation(mockClient, "apply", manifests)
+
+ assert.NoError(t, err)
+ assert.True(t, called)
+}
+
+func TestExecuteKubectlOperation_Apply_Error(t *testing.T) {
+ expectedErr := errors.New("kubectl apply failed")
+ called := false
+ mockClient := &mockKubectlClient{
+ applyFunc: func(ctx context.Context, files []string) error {
+ called = true
+ assert.Equal(t, []string{"error.yaml"}, files)
+ return expectedErr
+ },
+ }
+
+ manifests := []string{"error.yaml"}
+ err := testExecuteKubectlOperation(mockClient, "apply", manifests)
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), expectedErr.Error())
+ assert.True(t, called)
+}
+
+func TestExecuteKubectlOperation_Delete_Success(t *testing.T) {
+ called := false
+ mockClient := &mockKubectlClient{
+ deleteFunc: func(ctx context.Context, files []string) error {
+ called = true
+ assert.Equal(t, []string{"manifest1.yaml"}, files)
+ return nil
+ },
+ }
+
+ manifests := []string{"manifest1.yaml"}
+ err := testExecuteKubectlOperation(mockClient, "delete", manifests)
+
+ assert.NoError(t, err)
+ assert.True(t, called)
+}
+
+func TestExecuteKubectlOperation_Delete_Error(t *testing.T) {
+ expectedErr := errors.New("kubectl delete failed")
+ called := false
+ mockClient := &mockKubectlClient{
+ deleteFunc: func(ctx context.Context, files []string) error {
+ called = true
+ assert.Equal(t, []string{"error.yaml"}, files)
+ return expectedErr
+ },
+ }
+
+ manifests := []string{"error.yaml"}
+ err := testExecuteKubectlOperation(mockClient, "delete", manifests)
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), expectedErr.Error())
+ assert.True(t, called)
+}
+
+func TestExecuteKubectlOperation_RolloutRestart_Success(t *testing.T) {
+ called := false
+ mockClient := &mockKubectlClient{
+ rolloutRestartFunc: func(ctx context.Context, resources []string) error {
+ called = true
+ assert.Equal(t, []string{"deployment/nginx"}, resources)
+ return nil
+ },
+ }
+
+ resources := []string{"deployment/nginx"}
+ err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources)
+
+ assert.NoError(t, err)
+ assert.True(t, called)
+}
+
+func TestExecuteKubectlOperation_RolloutRestart_Error(t *testing.T) {
+ expectedErr := errors.New("kubectl rollout restart failed")
+ called := false
+ mockClient := &mockKubectlClient{
+ rolloutRestartFunc: func(ctx context.Context, resources []string) error {
+ called = true
+ assert.Equal(t, []string{"deployment/error"}, resources)
+ return expectedErr
+ },
+ }
+
+ resources := []string{"deployment/error"}
+ err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources)
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), expectedErr.Error())
+ assert.True(t, called)
+}
+
+func TestExecuteKubectlOperation_UnsupportedOperation(t *testing.T) {
+ mockClient := &mockKubectlClient{}
+
+ err := testExecuteKubectlOperation(mockClient, "unsupported", []string{})
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "unsupported operation")
+}
diff --git a/build/download_binaries.sh b/build/download_binaries.sh
index 05c2c780b..7494d94d3 100755
--- a/build/download_binaries.sh
+++ b/build/download_binaries.sh
@@ -7,21 +7,17 @@ ARCH=${2:-"amd64"}
BINARY_VERSION_FILE="./binary-version.json"
dockerVersion=$(jq -r '.docker' < "${BINARY_VERSION_FILE}")
-helmVersion=$(jq -r '.helm' < "${BINARY_VERSION_FILE}")
-kubectlVersion=$(jq -r '.kubectl' < "${BINARY_VERSION_FILE}")
mingitVersion=$(jq -r '.mingit' < "${BINARY_VERSION_FILE}")
mkdir -p dist
-echo "Checking and downloading binaries for docker ${dockerVersion}, helm ${helmVersion}, kubectl ${kubectlVersion} and mingit ${mingitVersion} (Windows only)"
+echo "Checking and downloading binaries for docker ${dockerVersion}, and mingit ${mingitVersion} (Windows only)"
# Determine the binary file names based on the platform
dockerBinary="dist/docker"
-kubectlBinary="dist/kubectl"
if [ "$PLATFORM" == "windows" ]; then
dockerBinary="dist/docker.exe"
- kubectlBinary="dist/kubectl.exe"
fi
# Check and download docker binary
@@ -32,14 +28,6 @@ else
echo "Docker binary already exists, skipping download."
fi
-# Check and download kubectl binary
-if [ ! -f "$kubectlBinary" ]; then
- echo "Downloading kubectl binary..."
- /usr/bin/env bash ./build/download_kubectl_binary.sh "$PLATFORM" "$ARCH" "$kubectlVersion"
-else
- echo "Kubectl binary already exists, skipping download."
-fi
-
# Check and download mingit binary only for Windows
if [ "$PLATFORM" == "windows" ]; then
if [ ! -f "dist/mingit" ]; then
diff --git a/build/download_kubectl_binary.sh b/build/download_kubectl_binary.sh
deleted file mode 100755
index 39c9c466b..000000000
--- a/build/download_kubectl_binary.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-if [[ $# -ne 3 ]]; then
- echo "Illegal number of parameters" >&2
- exit 1
-fi
-
-PLATFORM=$1
-ARCH=$2
-KUBECTL_VERSION=$3
-
-if [[ ${PLATFORM} == "windows" ]]; then
- wget --tries=3 --waitretry=30 --quiet -O "dist/kubectl.exe" "https://dl.k8s.io/${KUBECTL_VERSION}/bin/windows/amd64/kubectl.exe"
- chmod +x "dist/kubectl.exe"
-else
- wget --tries=3 --waitretry=30 --quiet -O "dist/kubectl" "https://dl.k8s.io/${KUBECTL_VERSION}/bin/${PLATFORM}/${ARCH}/kubectl"
- chmod +x "dist/kubectl"
-fi
diff --git a/build/linux/Dockerfile b/build/linux/Dockerfile
index eeded41c1..a79122a30 100644
--- a/build/linux/Dockerfile
+++ b/build/linux/Dockerfile
@@ -11,7 +11,6 @@ LABEL org.opencontainers.image.title="Portainer" \
com.docker.extension.additional-urls="[{\"title\":\"Website\",\"url\":\"https://www.portainer.io?utm_campaign=DockerCon&utm_source=DockerDesktop\"},{\"title\":\"Documentation\",\"url\":\"https://docs.portainer.io\"},{\"title\":\"Support\",\"url\":\"https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA\"}]"
COPY dist/docker /
-COPY dist/kubectl /
COPY dist/mustache-templates /mustache-templates/
COPY dist/portainer /
COPY dist/public /public/
diff --git a/build/linux/alpine.Dockerfile b/build/linux/alpine.Dockerfile
index e2ced7d3e..f74748a42 100644
--- a/build/linux/alpine.Dockerfile
+++ b/build/linux/alpine.Dockerfile
@@ -11,7 +11,6 @@ LABEL org.opencontainers.image.title="Portainer" \
com.docker.extension.additional-urls="[{\"title\":\"Website\",\"url\":\"https://www.portainer.io?utm_campaign=DockerCon&utm_source=DockerDesktop\"},{\"title\":\"Documentation\",\"url\":\"https://docs.portainer.io\"},{\"title\":\"Support\",\"url\":\"https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA\"}]"
COPY dist/docker /
-COPY dist/kubectl /
COPY dist/mustache-templates /mustache-templates/
COPY dist/portainer /
COPY dist/public /public/
diff --git a/build/windows/Dockerfile b/build/windows/Dockerfile
index 324e68f04..615744a83 100644
--- a/build/windows/Dockerfile
+++ b/build/windows/Dockerfile
@@ -10,7 +10,6 @@ USER ContainerAdministrator
COPY dist/mingit/ mingit/
COPY dist/docker.exe /
-COPY dist/kubectl.exe /
COPY dist/mustache-templates /mustache-templates/
COPY dist/portainer.exe /
COPY dist/public /public/
diff --git a/go.mod b/go.mod
index 0432cec7a..19ebbfa60 100644
--- a/go.mod
+++ b/go.mod
@@ -25,6 +25,7 @@ require (
github.com/gofrs/uuid v4.2.0+incompatible
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.6.0
+ github.com/google/uuid v1.6.0
github.com/gorilla/csrf v1.7.2
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
@@ -158,7 +159,6 @@ require (
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
- github.com/google/uuid v1.6.0 // indirect
github.com/gosuri/uitable v0.0.4 // indirect
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
@@ -184,6 +184,7 @@ require (
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
+ github.com/lithammer/dedent v1.1.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
@@ -242,6 +243,7 @@ require (
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
+ github.com/stretchr/objx v0.5.2 // indirect
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect
diff --git a/pkg/libkubectl/apply.go b/pkg/libkubectl/apply.go
new file mode 100644
index 000000000..bf77f174f
--- /dev/null
+++ b/pkg/libkubectl/apply.go
@@ -0,0 +1,23 @@
+package libkubectl
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+
+ "k8s.io/kubectl/pkg/cmd/apply"
+)
+
+func (c *Client) Apply(ctx context.Context, manifests []string) (string, error) {
+ buf := new(bytes.Buffer)
+
+ cmd := apply.NewCmdApply("kubectl", c.factory, c.streams)
+ cmd.SetArgs(manifestFilesToArgs(manifests))
+ cmd.SetOut(buf)
+
+ if err := cmd.ExecuteContext(ctx); 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 bfb86f172..f9d4137f6 100644
--- a/pkg/libkubectl/client.go
+++ b/pkg/libkubectl/client.go
@@ -41,8 +41,8 @@ func NewClient(libKubectlAccess *ClientAccess, namespace, kubeconfig string, ins
// If server and token are provided, they will be used to connect to the cluster
// If neither kubeconfigPath or server and token are provided, an error will be returned
func generateConfigFlags(token, server, namespace, kubeconfigPath string, insecure bool) (*genericclioptions.ConfigFlags, error) {
- if kubeconfigPath == "" && (server == "" || token == "") {
- return nil, errors.New("must provide either a kubeconfig path or a server and token")
+ if kubeconfigPath == "" && server == "" {
+ return nil, errors.New("must provide either a kubeconfig path or a server")
}
configFlags := genericclioptions.NewConfigFlags(true)
diff --git a/pkg/libkubectl/client_test.go b/pkg/libkubectl/client_test.go
deleted file mode 100644
index c2c7fd739..000000000
--- a/pkg/libkubectl/client_test.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package libkubectl
-
-import (
- "testing"
-)
-
-func TestNewClient(t *testing.T) {
- tests := []struct {
- name string
- libKubectlAccess ClientAccess
- namespace string
- kubeconfig string
- insecure bool
- wantErr bool
- errContains string
- }{
- {
- name: "valid client with token and server",
- libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
- namespace: "default",
- insecure: true,
- wantErr: false,
- },
- {
- name: "valid client with kubeconfig",
- kubeconfig: "/path/to/kubeconfig",
- namespace: "test-namespace",
- insecure: false,
- wantErr: false,
- },
- {
- name: "missing both token/server and kubeconfig",
- namespace: "default",
- insecure: false,
- wantErr: true,
- errContains: "must provide either a kubeconfig path or a server and token",
- },
- {
- name: "missing token with server",
- libKubectlAccess: ClientAccess{ServerUrl: "https://localhost:6443"},
- namespace: "default",
- insecure: false,
- wantErr: true,
- errContains: "must provide either a kubeconfig path or a server and token",
- },
- {
- name: "missing server with token",
- libKubectlAccess: ClientAccess{Token: "test-token"},
- namespace: "default",
- insecure: false,
- wantErr: true,
- errContains: "must provide either a kubeconfig path or a server and token",
- },
- {
- name: "empty namespace is valid",
- libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
- namespace: "",
- insecure: false,
- wantErr: false,
- },
- {
- name: "insecure true with valid credentials",
- libKubectlAccess: ClientAccess{Token: "test-token", ServerUrl: "https://localhost:6443"},
- namespace: "default",
- insecure: true,
- wantErr: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- client, err := NewClient(&tt.libKubectlAccess, tt.namespace, tt.kubeconfig, tt.insecure)
- if (err != nil) != tt.wantErr {
- t.Errorf("NewClient() error = %v, wantErr %v", err, tt.wantErr)
- return
- }
-
- if err != nil && tt.errContains != "" {
- if got := err.Error(); got != tt.errContains {
- t.Errorf("NewClient() error = %v, want error containing %v", got, tt.errContains)
- }
- return
- }
-
- if !tt.wantErr {
- if client == nil {
- t.Error("NewClient() returned nil client when no error was expected")
- return
- }
-
- // Verify client fields are properly initialized
- if client.factory == nil {
- t.Error("NewClient() client.factory is nil")
- }
- if client.out == nil {
- t.Error("NewClient() client.out is nil")
- }
- }
- })
- }
-}
diff --git a/pkg/libkubectl/delete.go b/pkg/libkubectl/delete.go
new file mode 100644
index 000000000..f8326a9f0
--- /dev/null
+++ b/pkg/libkubectl/delete.go
@@ -0,0 +1,24 @@
+package libkubectl
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+
+ "k8s.io/kubectl/pkg/cmd/delete"
+)
+
+func (c *Client) Delete(ctx context.Context, manifests []string) (string, error) {
+ buf := new(bytes.Buffer)
+
+ cmd := delete.NewCmdDelete(c.factory, c.streams)
+ cmd.SetArgs(manifestFilesToArgs(manifests))
+ cmd.Flags().Set("ignore-not-found", "true")
+ cmd.SetOut(buf)
+
+ if err := cmd.ExecuteContext(ctx); err != nil {
+ return "", fmt.Errorf("error deleting resources: %w", err)
+ }
+
+ return buf.String(), nil
+}
diff --git a/pkg/libkubectl/manifest.go b/pkg/libkubectl/manifest.go
new file mode 100644
index 000000000..cc63b5883
--- /dev/null
+++ b/pkg/libkubectl/manifest.go
@@ -0,0 +1,11 @@
+package libkubectl
+
+import "strings"
+
+func manifestFilesToArgs(manifestFiles []string) []string {
+ args := []string{}
+ for _, path := range manifestFiles {
+ args = append(args, "-f", strings.TrimSpace(path))
+ }
+ return args
+}
diff --git a/pkg/libkubectl/manifest_test.go b/pkg/libkubectl/manifest_test.go
new file mode 100644
index 000000000..9261c65ee
--- /dev/null
+++ b/pkg/libkubectl/manifest_test.go
@@ -0,0 +1,48 @@
+package libkubectl
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestManifestFilesToArgsHelper(t *testing.T) {
+ tests := []struct {
+ name string
+ manifestFiles []string
+ expectedArgs []string
+ }{
+ {
+ name: "empty list",
+ manifestFiles: []string{},
+ expectedArgs: []string{},
+ },
+ {
+ name: "single manifest",
+ manifestFiles: []string{"manifest.yaml"},
+ expectedArgs: []string{"-f", "manifest.yaml"},
+ },
+ {
+ name: "multiple manifests",
+ manifestFiles: []string{"manifest1.yaml", "manifest2.yaml"},
+ expectedArgs: []string{"-f", "manifest1.yaml", "-f", "manifest2.yaml"},
+ },
+ {
+ name: "manifests with whitespace",
+ manifestFiles: []string{" manifest1.yaml ", " manifest2.yaml"},
+ expectedArgs: []string{"-f", "manifest1.yaml", "-f", "manifest2.yaml"},
+ },
+ {
+ name: "kubernetes resource definitions",
+ manifestFiles: []string{"deployment/nginx", "service/web"},
+ expectedArgs: []string{"-f", "deployment/nginx", "-f", "service/web"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ args := manifestFilesToArgs(tt.manifestFiles)
+ assert.Equal(t, tt.expectedArgs, args)
+ })
+ }
+}
diff --git a/pkg/libkubectl/restart.go b/pkg/libkubectl/restart.go
new file mode 100644
index 000000000..4f7083787
--- /dev/null
+++ b/pkg/libkubectl/restart.go
@@ -0,0 +1,23 @@
+package libkubectl
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+
+ "k8s.io/kubectl/pkg/cmd/rollout"
+)
+
+func (c *Client) RolloutRestart(ctx context.Context, manifests []string) (string, error) {
+ buf := new(bytes.Buffer)
+
+ cmd := rollout.NewCmdRollout(c.factory, c.streams)
+ cmd.SetArgs(manifestFilesToArgs(manifests))
+ cmd.SetOut(buf)
+
+ if err := cmd.ExecuteContext(ctx); err != nil {
+ return "", fmt.Errorf("error restarting resources: %w", err)
+ }
+
+ return buf.String(), nil
+}
From 3b05505527aa9eadde78a3d2d783a99624a96734 Mon Sep 17 00:00:00 2001
From: Viktor Pettersson
Date: Thu, 8 May 2025 10:24:20 +0200
Subject: [PATCH 114/212] fix(update-schedules): display enriched error logs
for agent updates [BE-11756] (#693)
---
pkg/libstack/compose/status.go | 77 ++++++++++++++++++++++++++++------
1 file changed, 65 insertions(+), 12 deletions(-)
diff --git a/pkg/libstack/compose/status.go b/pkg/libstack/compose/status.go
index b6ad44ebf..d2fadff2c 100644
--- a/pkg/libstack/compose/status.go
+++ b/pkg/libstack/compose/status.go
@@ -1,14 +1,19 @@
package compose
import (
+ "bytes"
"context"
"fmt"
+ "io"
"time"
"github.com/portainer/portainer/pkg/libstack"
"github.com/compose-spec/compose-go/v2/types"
+ "github.com/docker/cli/cli/command"
"github.com/docker/compose/v2/pkg/api"
+ "github.com/docker/docker/api/types/container"
+ "github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
@@ -35,7 +40,7 @@ type service struct {
}
// docker container state can be one of "created", "running", "paused", "restarting", "removing", "exited", or "dead"
-func getServiceStatus(service service) (libstack.Status, string) {
+func getServiceStatus(ctx context.Context, service service) (libstack.Status, string) {
log.Debug().
Str("service", service.Name).
Str("state", service.State).
@@ -50,22 +55,70 @@ func getServiceStatus(service service) (libstack.Status, string) {
case "removing":
return libstack.StatusRemoving, ""
case "exited":
- if service.ExitCode != 0 {
- return libstack.StatusError, fmt.Sprintf("service %s exited with code %d", service.Name, service.ExitCode)
- }
- return libstack.StatusCompleted, ""
- case "dead":
- if service.ExitCode != 0 {
- return libstack.StatusError, fmt.Sprintf("service %s exited with code %d", service.Name, service.ExitCode)
+ if service.ExitCode == 0 {
+ return libstack.StatusCompleted, ""
}
- return libstack.StatusRemoved, ""
+ errorMessage, err := getContainerLogsTail(ctx, service)
+ if err != nil {
+ log.Error().
+ Err(err).
+ Str("service", service.Name).
+ Msg("failed to get logs from container")
+ errorMessage = fmt.Sprintf("service %s exited with code %d", service.Name, service.ExitCode)
+ }
+
+ return libstack.StatusError, errorMessage
+ case "dead":
+ if service.ExitCode == 0 {
+ return libstack.StatusRemoved, ""
+ }
+
+ errorMessage, err := getContainerLogsTail(ctx, service)
+ if err != nil {
+ log.Error().
+ Err(err).
+ Str("service", service.Name).
+ Msg("failed to get logs from container")
+ errorMessage = fmt.Sprintf("service %s exited with code %d", service.Name, service.ExitCode)
+ }
+
+ return libstack.StatusError, errorMessage
default:
return libstack.StatusUnknown, ""
}
}
-func aggregateStatuses(services []service) (libstack.Status, string) {
+func getContainerLogsTail(ctx context.Context, service service) (string, error) {
+ var combinedOutput bytes.Buffer
+
+ if err := withCli(ctx, libstack.Options{ProjectName: service.Project}, func(ctx context.Context, cli *command.DockerCli) error {
+ out, err := cli.Client().ContainerLogs(ctx, service.Name, container.LogsOptions{
+ ShowStdout: true,
+ ShowStderr: true,
+ Timestamps: true,
+ Follow: false,
+ Tail: "20",
+ })
+ if err != nil {
+ return errors.Wrap(err, "unable to get logs from container")
+ }
+ defer out.Close()
+
+ _, err = io.Copy(&combinedOutput, out)
+ if err != nil {
+ return errors.Wrap(err, "unable to read container logs")
+ }
+
+ return nil
+ }); err != nil {
+ return "", errors.Wrap(err, "unable to get logs from container")
+ }
+
+ return combinedOutput.String(), nil
+}
+
+func aggregateStatuses(ctx context.Context, services []service) (libstack.Status, string) {
servicesCount := len(services)
if servicesCount == 0 {
@@ -78,7 +131,7 @@ func aggregateStatuses(services []service) (libstack.Status, string) {
statusCounts := make(map[libstack.Status]int)
errorMessage := ""
for _, service := range services {
- status, serviceError := getServiceStatus(service)
+ status, serviceError := getServiceStatus(ctx, service)
if serviceError != "" {
errorMessage = serviceError
}
@@ -148,7 +201,7 @@ func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status
return waitResult
}
- aggregateStatus, errorMessage := aggregateStatuses(services)
+ aggregateStatus, errorMessage := aggregateStatuses(ctx, services)
if aggregateStatus == status {
return waitResult
}
From b9b734ceda3332a29aebeb6608a20f5a9cabbf4a Mon Sep 17 00:00:00 2001
From: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Date: Fri, 9 May 2025 03:39:15 +0100
Subject: [PATCH 115/212] Update bug report template for 2.27.6 (#721)
---
.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 bb0fe9553..b83e3792a 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -99,6 +99,7 @@ body:
- '2.29.0'
- '2.28.1'
- '2.28.0'
+ - '2.27.6'
- '2.27.5'
- '2.27.4'
- '2.27.3'
From 9fdc535d6bcc5ba561fdc9910e66c17e88429208 Mon Sep 17 00:00:00 2001
From: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Date: Thu, 8 May 2025 23:39:29 -0300
Subject: [PATCH 116/212] fix(csrf): skip the trusted origins check for
plain-text HTTP requests BE-11832 (#710)
Co-authored-by: andres-portainer
Co-authored-by: oscarzhou
---
.../middlewares/plaintext_http_request.go | 36 +++++++++++++++++++
api/http/server.go | 2 +-
go.mod | 3 +-
go.sum | 4 +--
4 files changed, 40 insertions(+), 5 deletions(-)
create mode 100644 api/http/middlewares/plaintext_http_request.go
diff --git a/api/http/middlewares/plaintext_http_request.go b/api/http/middlewares/plaintext_http_request.go
new file mode 100644
index 000000000..668346098
--- /dev/null
+++ b/api/http/middlewares/plaintext_http_request.go
@@ -0,0 +1,36 @@
+package middlewares
+
+import (
+ "net/http"
+ "slices"
+
+ "github.com/gorilla/csrf"
+)
+
+var (
+ // Idempotent (safe) methods as defined by RFC7231 section 4.2.2.
+ safeMethods = []string{"GET", "HEAD", "OPTIONS", "TRACE"}
+)
+
+type plainTextHTTPRequestHandler struct {
+ next http.Handler
+}
+
+func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if slices.Contains(safeMethods, r.Method) {
+ h.next.ServeHTTP(w, r)
+ return
+ }
+
+ req := r
+ // If original request was HTTPS (via proxy), keep CSRF checks.
+ if xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" {
+ req = csrf.PlaintextHTTPRequest(r)
+ }
+
+ h.next.ServeHTTP(w, req)
+}
+
+func PlaintextHTTPRequest(next http.Handler) http.Handler {
+ return &plainTextHTTPRequestHandler{next: next}
+}
diff --git a/api/http/server.go b/api/http/server.go
index 3b3fff77d..183a78c04 100644
--- a/api/http/server.go
+++ b/api/http/server.go
@@ -349,7 +349,7 @@ func (server *Server) Start() error {
log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server")
httpServer := &http.Server{
Addr: server.BindAddress,
- Handler: handler,
+ Handler: middlewares.PlaintextHTTPRequest(handler),
ErrorLog: errorLogger,
}
diff --git a/go.mod b/go.mod
index 19ebbfa60..3193c560c 100644
--- a/go.mod
+++ b/go.mod
@@ -26,7 +26,7 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.6.0
github.com/google/uuid v1.6.0
- github.com/gorilla/csrf v1.7.2
+ github.com/gorilla/csrf v1.7.3
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/go-version v1.7.0
@@ -243,7 +243,6 @@ require (
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
- github.com/stretchr/objx v0.5.2 // indirect
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect
diff --git a/go.sum b/go.sum
index eed5e28b8..3f4be510b 100644
--- a/go.sum
+++ b/go.sum
@@ -338,8 +338,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
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.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
-github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
+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/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
From 1abdf42f991a8e867cbc1f9c4bd779eb3de686bb Mon Sep 17 00:00:00 2001
From: Devon Steenberg
Date: Mon, 12 May 2025 11:18:04 +1200
Subject: [PATCH 117/212] feat(libstack): expose env vars with PORTAINER_
prefix [BE-11661] (#687)
---
.../EdgeScriptSettingsFieldset.tsx | 5 +
pkg/libstack/compose/composeplugin.go | 11 +
pkg/libstack/compose/composeplugin_test.go | 216 ++++++++++++++++++
3 files changed, 232 insertions(+)
diff --git a/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx b/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx
index 226886efa..cab25a0af 100644
--- a/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx
+++ b/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx
@@ -87,6 +87,11 @@ export function EdgeScriptSettingsFieldset({
/>
+
+ For security purposes, only environment variables prefixed with
+ 'PORTAINER_' will be accessible.
+
+
Date: Tue, 13 May 2025 11:35:44 +1200
Subject: [PATCH 118/212] fix(kubectl): rollout restart [r8s-322] (#729)
---
api/exec/kubernetes_deploy.go | 12 ++++----
pkg/libkubectl/apply.go | 2 +-
pkg/libkubectl/delete.go | 2 +-
pkg/libkubectl/manifest.go | 11 -------
pkg/libkubectl/manifest_test.go | 48 -----------------------------
pkg/libkubectl/resource.go | 20 +++++++++++++
pkg/libkubectl/resource_test.go | 53 +++++++++++++++++++++++++++++++++
pkg/libkubectl/restart.go | 5 +++-
8 files changed, 85 insertions(+), 68 deletions(-)
delete mode 100644 pkg/libkubectl/manifest.go
delete mode 100644 pkg/libkubectl/manifest_test.go
create mode 100644 pkg/libkubectl/resource.go
create mode 100644 pkg/libkubectl/resource_test.go
diff --git a/api/exec/kubernetes_deploy.go b/api/exec/kubernetes_deploy.go
index 0bdd5caa2..940276929 100644
--- a/api/exec/kubernetes_deploy.go
+++ b/api/exec/kubernetes_deploy.go
@@ -76,16 +76,16 @@ func (deployer *KubernetesDeployer) getToken(userID portainer.UserID, endpoint *
}
// Deploy upserts Kubernetes resources defined in manifest(s)
-func (deployer *KubernetesDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
- return deployer.command("apply", userID, endpoint, manifestFiles, namespace)
+func (deployer *KubernetesDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
+ return deployer.command("apply", userID, endpoint, resources, namespace)
}
// Remove deletes Kubernetes resources defined in manifest(s)
-func (deployer *KubernetesDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
- return deployer.command("delete", userID, endpoint, manifestFiles, namespace)
+func (deployer *KubernetesDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
+ return deployer.command("delete", userID, endpoint, resources, namespace)
}
-func (deployer *KubernetesDeployer) command(operation string, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
+func (deployer *KubernetesDeployer) command(operation string, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
token, err := deployer.getToken(userID, endpoint, endpoint.Type == portainer.KubernetesLocalEnvironment)
if err != nil {
return "", errors.Wrap(err, "failed generating a user token")
@@ -120,7 +120,7 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
return "", errors.Errorf("unsupported operation: %s", operation)
}
- output, err := operationFunc(context.Background(), manifestFiles)
+ output, err := operationFunc(context.Background(), resources)
if err != nil {
return "", errors.Wrapf(err, "failed to execute kubectl %s command", operation)
}
diff --git a/pkg/libkubectl/apply.go b/pkg/libkubectl/apply.go
index bf77f174f..5dad5adc5 100644
--- a/pkg/libkubectl/apply.go
+++ b/pkg/libkubectl/apply.go
@@ -12,7 +12,7 @@ func (c *Client) Apply(ctx context.Context, manifests []string) (string, error)
buf := new(bytes.Buffer)
cmd := apply.NewCmdApply("kubectl", c.factory, c.streams)
- cmd.SetArgs(manifestFilesToArgs(manifests))
+ cmd.SetArgs(resourcesToArgs(manifests))
cmd.SetOut(buf)
if err := cmd.ExecuteContext(ctx); err != nil {
diff --git a/pkg/libkubectl/delete.go b/pkg/libkubectl/delete.go
index f8326a9f0..bb093ab4d 100644
--- a/pkg/libkubectl/delete.go
+++ b/pkg/libkubectl/delete.go
@@ -12,7 +12,7 @@ func (c *Client) Delete(ctx context.Context, manifests []string) (string, error)
buf := new(bytes.Buffer)
cmd := delete.NewCmdDelete(c.factory, c.streams)
- cmd.SetArgs(manifestFilesToArgs(manifests))
+ cmd.SetArgs(resourcesToArgs(manifests))
cmd.Flags().Set("ignore-not-found", "true")
cmd.SetOut(buf)
diff --git a/pkg/libkubectl/manifest.go b/pkg/libkubectl/manifest.go
deleted file mode 100644
index cc63b5883..000000000
--- a/pkg/libkubectl/manifest.go
+++ /dev/null
@@ -1,11 +0,0 @@
-package libkubectl
-
-import "strings"
-
-func manifestFilesToArgs(manifestFiles []string) []string {
- args := []string{}
- for _, path := range manifestFiles {
- args = append(args, "-f", strings.TrimSpace(path))
- }
- return args
-}
diff --git a/pkg/libkubectl/manifest_test.go b/pkg/libkubectl/manifest_test.go
deleted file mode 100644
index 9261c65ee..000000000
--- a/pkg/libkubectl/manifest_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package libkubectl
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestManifestFilesToArgsHelper(t *testing.T) {
- tests := []struct {
- name string
- manifestFiles []string
- expectedArgs []string
- }{
- {
- name: "empty list",
- manifestFiles: []string{},
- expectedArgs: []string{},
- },
- {
- name: "single manifest",
- manifestFiles: []string{"manifest.yaml"},
- expectedArgs: []string{"-f", "manifest.yaml"},
- },
- {
- name: "multiple manifests",
- manifestFiles: []string{"manifest1.yaml", "manifest2.yaml"},
- expectedArgs: []string{"-f", "manifest1.yaml", "-f", "manifest2.yaml"},
- },
- {
- name: "manifests with whitespace",
- manifestFiles: []string{" manifest1.yaml ", " manifest2.yaml"},
- expectedArgs: []string{"-f", "manifest1.yaml", "-f", "manifest2.yaml"},
- },
- {
- name: "kubernetes resource definitions",
- manifestFiles: []string{"deployment/nginx", "service/web"},
- expectedArgs: []string{"-f", "deployment/nginx", "-f", "service/web"},
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- args := manifestFilesToArgs(tt.manifestFiles)
- assert.Equal(t, tt.expectedArgs, args)
- })
- }
-}
diff --git a/pkg/libkubectl/resource.go b/pkg/libkubectl/resource.go
new file mode 100644
index 000000000..705560513
--- /dev/null
+++ b/pkg/libkubectl/resource.go
@@ -0,0 +1,20 @@
+package libkubectl
+
+import "strings"
+
+func isManifestFile(resource string) bool {
+ trimmedResource := strings.TrimSpace(resource)
+ return strings.HasSuffix(trimmedResource, ".yaml") || strings.HasSuffix(trimmedResource, ".yml")
+}
+
+func resourcesToArgs(resources []string) []string {
+ args := []string{}
+ for _, resource := range resources {
+ if isManifestFile(resource) {
+ args = append(args, "-f", strings.TrimSpace(resource))
+ } else {
+ args = append(args, resource)
+ }
+ }
+ return args
+}
diff --git a/pkg/libkubectl/resource_test.go b/pkg/libkubectl/resource_test.go
new file mode 100644
index 000000000..47016f4d4
--- /dev/null
+++ b/pkg/libkubectl/resource_test.go
@@ -0,0 +1,53 @@
+package libkubectl
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestResourcesToArgsHelper(t *testing.T) {
+ tests := []struct {
+ name string
+ resources []string
+ expectedArgs []string
+ }{
+ {
+ name: "empty list",
+ resources: []string{},
+ expectedArgs: []string{},
+ },
+ {
+ name: "single manifest file",
+ resources: []string{"manifest.yaml"},
+ expectedArgs: []string{"-f", "manifest.yaml"},
+ },
+ {
+ name: "multiple manifest files",
+ resources: []string{"manifest1.yaml", "manifest2.yaml"},
+ expectedArgs: []string{"-f", "manifest1.yaml", "-f", "manifest2.yaml"},
+ },
+ {
+ name: "manifests with whitespace",
+ resources: []string{" manifest1.yaml ", " manifest2.yaml"},
+ expectedArgs: []string{"-f", "manifest1.yaml", "-f", "manifest2.yaml"},
+ },
+ {
+ name: "kubernetes resource definitions",
+ resources: []string{"deployment/nginx", "service/web"},
+ expectedArgs: []string{"deployment/nginx", "service/web"},
+ },
+ {
+ name: "rollout restart",
+ resources: []string{"deployment/nginx", "service/web"},
+ expectedArgs: []string{"deployment/nginx", "service/web"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ args := resourcesToArgs(tt.resources)
+ assert.Equal(t, tt.expectedArgs, args)
+ })
+ }
+}
diff --git a/pkg/libkubectl/restart.go b/pkg/libkubectl/restart.go
index 4f7083787..fcfa7fd15 100644
--- a/pkg/libkubectl/restart.go
+++ b/pkg/libkubectl/restart.go
@@ -12,7 +12,10 @@ func (c *Client) RolloutRestart(ctx context.Context, manifests []string) (string
buf := new(bytes.Buffer)
cmd := rollout.NewCmdRollout(c.factory, c.streams)
- cmd.SetArgs(manifestFilesToArgs(manifests))
+ args := []string{"restart"}
+ args = append(args, resourcesToArgs(manifests)...)
+
+ cmd.SetArgs(args)
cmd.SetOut(buf)
if err := cmd.ExecuteContext(ctx); err != nil {
From 0b6972917354144c11816fa92c1f670ed699dcc4 Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Tue, 13 May 2025 14:28:42 +1200
Subject: [PATCH 119/212] chrore(microk8s): add deprecation notice [r8s-320]
(#728)
---
app/react/components/Alert/Alert.tsx | 36 +++++++++++--------
.../FormSection/FormSection.tsx | 5 +--
2 files changed, 24 insertions(+), 17 deletions(-)
diff --git a/app/react/components/Alert/Alert.tsx b/app/react/components/Alert/Alert.tsx
index 02c6e995e..a61e62c98 100644
--- a/app/react/components/Alert/Alert.tsx
+++ b/app/react/components/Alert/Alert.tsx
@@ -6,36 +6,36 @@ import { Icon } from '@@/Icon';
type AlertType = 'success' | 'error' | 'info' | 'warn';
-const alertSettings: Record<
+export const alertSettings: Record<
AlertType,
{ container: string; header: string; body: string; icon: ReactNode }
> = {
success: {
container:
- 'border-green-4 bg-green-2 th-dark:bg-green-3 th-dark:border-green-5',
- header: 'text-green-8',
- body: 'text-green-7',
+ 'border-green-4 bg-green-2 th-dark:bg-green-10 th-dark:border-green-8 th-highcontrast:bg-green-10 th-highcontrast:border-green-8',
+ header: 'text-green-8 th-dark:text-white th-highcontrast:text-white',
+ body: 'text-green-7 th-dark:text-white th-highcontrast:text-white',
icon: CheckCircle,
},
error: {
container:
- 'border-error-4 bg-error-2 th-dark:bg-error-3 th-dark:border-error-5',
- header: 'text-error-8',
- body: 'text-error-7',
+ 'border-error-4 bg-error-2 th-dark:bg-error-10 th-dark:border-error-8 th-highcontrast:bg-error-10 th-highcontrast:border-error-8',
+ header: 'text-error-8 th-dark:text-white th-highcontrast:text-white',
+ body: 'text-error-7 th-dark:text-white th-highcontrast:text-white',
icon: XCircle,
},
info: {
container:
- 'border-blue-4 bg-blue-2 th-dark:bg-blue-3 th-dark:border-blue-5',
- header: 'text-blue-8',
- body: 'text-blue-7',
+ 'border-blue-4 bg-blue-2 th-dark:bg-blue-10 th-dark:border-blue-8 th-highcontrast:bg-blue-10 th-highcontrast:border-blue-8',
+ header: 'text-blue-8 th-dark:text-white th-highcontrast:text-white',
+ body: 'text-blue-7 th-dark:text-white th-highcontrast:text-white',
icon: AlertCircle,
},
warn: {
container:
- 'border-warning-4 bg-warning-2 th-dark:bg-warning-3 th-dark:border-warning-5',
- header: 'text-warning-8',
- body: 'text-warning-7',
+ 'border-warning-4 bg-warning-2 th-dark:bg-warning-10 th-dark:border-warning-8 th-highcontrast:bg-warning-10 th-highcontrast:border-warning-8',
+ header: 'text-warning-8 th-dark:text-white th-highcontrast:text-white',
+ body: 'text-warning-7 th-dark:text-white th-highcontrast:text-white',
icon: AlertTriangle,
},
};
@@ -76,12 +76,18 @@ export function Alert({
);
}
-function AlertContainer({
+export function AlertContainer({
className,
children,
}: PropsWithChildren<{ className?: string }>) {
return (
-
+
{children}
);
diff --git a/app/react/components/form-components/FormSection/FormSection.tsx b/app/react/components/form-components/FormSection/FormSection.tsx
index 05bb9ddbf..6ea747762 100644
--- a/app/react/components/form-components/FormSection/FormSection.tsx
+++ b/app/react/components/form-components/FormSection/FormSection.tsx
@@ -45,8 +45,9 @@ export function FormSection({
{title}
-
- {isExpanded && children}
+ {/* col-sm-12 in the title has a 'float: left' style - 'clear-both' makes sure it doesn't get in the way of the next div */}
+ {/* https://stackoverflow.com/questions/7759837/put-divs-below-floatleft-divs */}
+ {isExpanded &&
{children}
}
);
}
From dfa32b6755ede817a690236f2d24070e3b287ce5 Mon Sep 17 00:00:00 2001
From: Steven Kang
Date: Tue, 13 May 2025 16:33:14 +1200
Subject: [PATCH 120/212] chore: add KaaS deprecation notice (#727)
Co-authored-by: testA113
---
.../EnvironmentTypeSelectView/environment-types.ts | 2 +-
.../EnvironmentsCreationView.tsx | 14 ++++++++++++++
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts
index 695feb53e..9c590687c 100644
--- a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts
+++ b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts
@@ -69,7 +69,7 @@ export const newEnvironmentTypes: EnvironmentOption[] = [
{
id: 'kaas',
value: 'kaas',
- label: 'Provision KaaS Cluster',
+ label: 'Provision KaaS Cluster (Deprecated)',
description:
"Provision a Kubernetes cluster via a cloud provider's Kubernetes as a Service",
icon: KaaS,
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx
index a56095bfc..666193743 100644
--- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx
+++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/EnvironmentsCreationView.tsx
@@ -17,6 +17,7 @@ import { PageHeader } from '@@/PageHeader';
import { Button } from '@@/buttons';
import { FormSection } from '@@/form-components/FormSection';
import { Icon } from '@@/Icon';
+import { Alert } from '@@/Alert';
import {
EnvironmentOptionValue,
@@ -84,6 +85,19 @@ export function EnvironmentCreationView() {
+ {currentStep.id === 'kaas' && (
+
+ Provisioning a KaaS environment from Portainer is deprecated
+ and will be removed in a future release. You will still be
+ able to use any Kubernetes clusters provisioned using this
+ method but will no longer have access to any of the
+ KaaS-specific management functionality.
+
+ )}
Date: Tue, 13 May 2025 22:15:04 +1200
Subject: [PATCH 121/212] feat(helm): helm actions [r8s-259] (#715)
Co-authored-by: James Player
Co-authored-by: Cara Ryan
Co-authored-by: stevensbkang
---
api/http/handler/helm/helm_install.go | 4 +-
app/assets/css/app.css | 6 +-
app/assets/css/bootstrap-override.css | 4 +-
app/assets/css/theme.css | 4 +-
.../views/configs/create/createconfig.html | 2 +-
app/docker/views/images/build/buildimage.html | 2 +-
app/kubernetes/__module.js | 2 +-
.../kubernetesConfigurationData.html | 2 +-
.../components/code-editor/code-editor.html | 2 +-
.../components/code-editor/code-editor.js | 2 +-
.../form-components/web-editor-form/index.js | 2 +-
.../web-editor-form/web-editor-form.html | 2 +-
app/portainer/react/components/index.ts | 4 +-
.../views/stacks/create/createstack.html | 2 +-
app/portainer/views/stacks/edit/stack.html | 2 +-
app/react/common/date-utils.ts | 16 +
app/react/components/Badge/Badge.stories.tsx | 2 +
app/react/components/Badge/Badge.tsx | 8 +-
.../Blocklist/BlocklistItem.stories.tsx | 75 +++++
app/react/components/Card/Card.tsx | 2 +-
app/react/components/CodeEditor.tsx | 228 -------------
.../{ => CodeEditor}/CodeEditor.module.css | 46 ++-
.../{ => CodeEditor}/CodeEditor.test.tsx | 29 +-
.../components/CodeEditor/CodeEditor.tsx | 158 +++++++++
.../components/CodeEditor/DiffViewer.test.tsx | 101 ++++++
.../components/CodeEditor/DiffViewer.tsx | 138 ++++++++
.../components/CodeEditor/FileNameHeader.tsx | 71 ++++
app/react/components/CodeEditor/index.ts | 1 +
.../CodeEditor/useCodeEditorExtensions.ts | 91 +++++
.../components/InlineLoader/InlineLoader.tsx | 10 +-
.../components/RadioGroup/RadioGroup.tsx | 23 +-
app/react/components/Sheet.tsx | 159 +++++++++
app/react/components/WebEditorForm.tsx | 2 +
app/react/components/Widget/WidgetIcon.tsx | 11 +
app/react/components/Widget/WidgetTitle.tsx | 12 +-
.../buttons/CopyButton/CopyButton.stories.tsx | 4 +-
app/react/components/datatables/Datatable.tsx | 32 +-
.../components/datatables/DatatableHeader.tsx | 4 +-
.../components/datatables/TableContainer.tsx | 4 +-
.../components/datatables/TableTitle.tsx | 2 +-
.../AdvancedMode.tsx | 2 +-
.../components/modals/Modal/Modal.module.css | 6 -
app/react/components/modals/Modal/Modal.tsx | 10 +-
.../CreateView/CreateEdgeJobForm.tsx | 2 +-
.../UpdateEdgeJobForm/UpdateEdgeJobForm.tsx | 2 +-
.../CreateView/DockerContentField.tsx | 2 +-
.../CreateView/KubeManifestForm.tsx | 2 +-
.../EditEdgeStackForm/ComposeForm.tsx | 2 +-
.../EditEdgeStackForm/KubernetesForm.tsx | 2 +-
app/react/hooks/useDebounce.ts | 12 +-
.../ApplicationEventsDatatable.tsx | 31 +-
.../ApplicationsDatatable.tsx | 1 +
.../ListView/ApplicationsDatatable/SubRow.tsx | 5 +-
.../components/EditYamlFormSection.tsx | 2 +-
.../EventsDatatable/EventsDatatable.tsx | 9 +-
.../EventsDatatable/columns/eventType.tsx | 12 +
.../EventsDatatable/columns/index.ts | 3 +-
.../EventsDatatable/columns/kind.tsx | 13 +
.../EventsDatatable/columns/name.tsx | 16 +
.../kubernetes/components/YAMLInspector.tsx | 2 +-
.../ChartActions/ChartActions.tsx | 44 ++-
.../ChartActions/RollbackButton.test.tsx | 8 +-
.../ChartActions/RollbackButton.tsx | 25 +-
.../ChartActions/UpgradeButton.test.tsx | 182 ++++++++++
.../ChartActions/UpgradeButton.tsx | 167 +++++++++
.../ChartActions/UpgradeHelmModal.tsx | 151 +++++++++
.../HelmApplicationView.test.tsx | 316 +++++++++++++-----
.../HelmApplicationView.tsx | 103 ++++--
.../HelmRevisionItem.test.tsx | 116 +++++++
.../HelmApplicationView/HelmRevisionItem.tsx | 49 +++
.../HelmApplicationView/HelmRevisionList.tsx | 43 +++
.../HelmRevisionListSheet.tsx | 40 +++
.../helm/HelmApplicationView/HelmSummary.tsx | 70 +---
.../ReleaseDetails/DiffControl.test.tsx | 193 +++++++++++
.../ReleaseDetails/DiffControl.tsx | 146 ++++++++
.../ReleaseDetails/DiffViewSection.tsx | 55 +++
.../HelmEventsDatatable.test.tsx | 242 ++++++++++++++
.../ReleaseDetails/HelmEventsDatatable.tsx | 77 +++++
.../ReleaseDetails/ManifestDetails.tsx | 58 +++-
.../ReleaseDetails/NotesDetails.tsx | 43 ++-
.../ReleaseDetails/ReleaseTabs.tsx | 252 ++++++++++++--
.../ResourcesTable/DescribeModal.test.tsx | 12 +-
.../ResourcesTable/ResourcesTable.tsx | 11 +-
.../ResourcesTable/columns/status.tsx | 4 +
.../ReleaseDetails/ValuesDetails.tsx | 84 +++--
.../ReleaseDetails/types.ts | 26 ++
.../ReleaseDetails/useHelmReleaseToCompare.ts | 60 ++++
.../queries/useHelmHistory.ts | 44 +++
.../queries/useHelmRelease.ts | 46 ++-
.../queries/useHelmRepositories.ts | 99 ++++++
.../queries/useUpdateHelmReleaseMutation.ts | 44 +++
.../helm/HelmTemplates/HelmTemplates.tsx | 15 +-
.../HelmTemplates/HelmTemplatesList.test.tsx | 6 +-
.../helm/HelmTemplates/HelmTemplatesList.tsx | 23 +-
.../HelmTemplatesSelectedItem.test.tsx | 5 +-
.../HelmTemplatesSelectedItem.tsx | 9 +-
.../HelmTemplates/queries/useHelmChartList.ts | 79 -----
.../kubernetes/helm/helm-status-utils.ts | 48 +++
.../helm/queries/useHelmChartList.ts | 105 ++++++
app/react/kubernetes/helm/types.ts | 6 +-
app/react/kubernetes/queries/useEvents.ts | 20 +-
.../HelmRepositoryDatatable.tsx | 1 +
.../custom-templates/CreateView/InnerForm.tsx | 2 +-
.../custom-templates/EditView/InnerForm.tsx | 2 +-
.../DeployForm.tsx | 2 +-
app/setup-tests/mock-codemirror.tsx | 15 +
app/setup-tests/mock-localizeDate.ts | 18 +
package.json | 4 +
pkg/libhelm/options/install_options.go | 1 +
pkg/libhelm/sdk/common.go | 3 +-
pkg/libhelm/sdk/get.go | 10 +-
pkg/libhelm/sdk/history.go | 7 +
pkg/libhelm/sdk/install.go | 3 +-
pkg/libhelm/sdk/upgrade.go | 2 +-
pkg/libhelm/sdk/values.go | 11 +-
tailwind.config.js | 1 +
yarn.lock | 242 +++++++++++++-
117 files changed, 4161 insertions(+), 696 deletions(-)
create mode 100644 app/react/common/date-utils.ts
create mode 100644 app/react/components/Blocklist/BlocklistItem.stories.tsx
delete mode 100644 app/react/components/CodeEditor.tsx
rename app/react/components/{ => CodeEditor}/CodeEditor.module.css (78%)
rename app/react/components/{ => CodeEditor}/CodeEditor.test.tsx (78%)
create mode 100644 app/react/components/CodeEditor/CodeEditor.tsx
create mode 100644 app/react/components/CodeEditor/DiffViewer.test.tsx
create mode 100644 app/react/components/CodeEditor/DiffViewer.tsx
create mode 100644 app/react/components/CodeEditor/FileNameHeader.tsx
create mode 100644 app/react/components/CodeEditor/index.ts
create mode 100644 app/react/components/CodeEditor/useCodeEditorExtensions.ts
create mode 100644 app/react/components/Sheet.tsx
create mode 100644 app/react/components/Widget/WidgetIcon.tsx
create mode 100644 app/react/kubernetes/components/EventsDatatable/columns/name.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.test.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmRevisionList.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/HelmRevisionListSheet.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.test.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffViewSection.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/types.ts
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts
create mode 100644 app/react/kubernetes/helm/HelmApplicationView/queries/useUpdateHelmReleaseMutation.ts
delete mode 100644 app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartList.ts
create mode 100644 app/react/kubernetes/helm/helm-status-utils.ts
create mode 100644 app/react/kubernetes/helm/queries/useHelmChartList.ts
create mode 100644 app/setup-tests/mock-localizeDate.ts
diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go
index d7254c1f4..cd4985770 100644
--- a/api/http/handler/helm/helm_install.go
+++ b/api/http/handler/helm/helm_install.go
@@ -26,6 +26,7 @@ type installChartPayload struct {
Chart string `json:"chart"`
Repo string `json:"repo"`
Values string `json:"values"`
+ Version string `json:"version"`
}
var errChartNameInvalid = errors.New("invalid chart name. " +
@@ -101,6 +102,7 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
installOpts := options.InstallOptions{
Name: p.Name,
Chart: p.Chart,
+ Version: p.Version,
Namespace: p.Namespace,
Repo: p.Repo,
KubernetesClusterAccess: clusterAccess,
@@ -192,7 +194,7 @@ func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte,
g := new(errgroup.Group)
for _, resource := range yamlResources {
g.Go(func() error {
- tmpfile, err := os.CreateTemp("", "helm-manifest-*")
+ tmpfile, err := os.CreateTemp("", "helm-manifest-*.yaml")
if err != nil {
return errors.Wrap(err, "failed to create a tmp helm manifest file")
}
diff --git a/app/assets/css/app.css b/app/assets/css/app.css
index afcf49470..85d8fbca5 100644
--- a/app/assets/css/app.css
+++ b/app/assets/css/app.css
@@ -32,6 +32,10 @@ body {
color: var(--text-body-color) !important;
}
+.bg-widget-color {
+ background: var(--bg-widget-color);
+}
+
html,
body,
#page-wrapper,
@@ -224,7 +228,7 @@ input[type='checkbox'] {
.blocklist-item--selected {
background-color: var(--bg-blocklist-item-selected-color);
- border: 2px solid var(--border-blocklist-item-selected-color);
+ border-color: var(--border-blocklist-item-selected-color);
color: var(--text-blocklist-item-selected-color);
}
diff --git a/app/assets/css/bootstrap-override.css b/app/assets/css/bootstrap-override.css
index 19e63397d..cbded5622 100644
--- a/app/assets/css/bootstrap-override.css
+++ b/app/assets/css/bootstrap-override.css
@@ -20,9 +20,7 @@
}
.vertical-center {
- display: inline-flex;
- align-items: center;
- gap: 5px;
+ @apply inline-flex items-center gap-1;
}
.flex-center {
diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css
index f104f3e7b..eb2d36882 100644
--- a/app/assets/css/theme.css
+++ b/app/assets/css/theme.css
@@ -268,7 +268,7 @@
--bg-body-color: var(--grey-2);
--bg-btn-default-color: var(--grey-3);
--bg-blocklist-hover-color: var(--ui-gray-iron-10);
- --bg-blocklist-item-selected-color: var(--grey-3);
+ --bg-blocklist-item-selected-color: var(--ui-gray-iron-10);
--bg-card-color: var(--grey-1);
--bg-checkbox-border-color: var(--grey-8);
--bg-code-color: var(--grey-2);
@@ -388,7 +388,7 @@
--border-navtabs-color: var(--grey-38);
--border-pre-color: var(--grey-3);
--border-blocklist: var(--ui-gray-9);
- --border-blocklist-item-selected-color: var(--grey-38);
+ --border-blocklist-item-selected-color: var(--grey-31);
--border-pagination-span-color: var(--grey-1);
--border-pagination-hover-color: var(--grey-3);
--border-panel-color: var(--grey-2);
diff --git a/app/docker/views/configs/create/createconfig.html b/app/docker/views/configs/create/createconfig.html
index 945330c06..6ac0bef06 100644
--- a/app/docker/views/configs/create/createconfig.html
+++ b/app/docker/views/configs/create/createconfig.html
@@ -18,7 +18,7 @@
diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js
index 98607e569..adb937cce 100644
--- a/app/kubernetes/__module.js
+++ b/app/kubernetes/__module.js
@@ -145,7 +145,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
const helmApplication = {
name: 'kubernetes.helm',
- url: '/helm/:namespace/:name',
+ url: '/helm/:namespace/:name?revision&tab',
views: {
'content@': {
component: 'kubernetesHelmApplicationView',
diff --git a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html
index ca2caebef..193ce8be0 100644
--- a/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html
+++ b/app/kubernetes/components/kubernetes-configuration-data/kubernetesConfigurationData.html
@@ -165,7 +165,7 @@
value="$ctrl.formValues.DataYaml"
on-change="($ctrl.editorUpdate)"
yml="true"
- placeholder="Define or paste key-value pairs, one pair per line"
+ text-tip="Define or paste key-value pairs, one pair per line"
>
diff --git a/app/portainer/components/code-editor/code-editor.html b/app/portainer/components/code-editor/code-editor.html
index 2cf42b272..446da5dab 100644
--- a/app/portainer/components/code-editor/code-editor.html
+++ b/app/portainer/components/code-editor/code-editor.html
@@ -1,6 +1,6 @@
diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html
index 14c5c8628..8ecf54e06 100644
--- a/app/portainer/views/stacks/edit/stack.html
+++ b/app/portainer/views/stacks/edit/stack.html
@@ -160,7 +160,7 @@
{message[type]};
}
diff --git a/app/react/components/Badge/Badge.tsx b/app/react/components/Badge/Badge.tsx
index e31e3b122..5babb5733 100644
--- a/app/react/components/Badge/Badge.tsx
+++ b/app/react/components/Badge/Badge.tsx
@@ -9,7 +9,8 @@ export type BadgeType =
| 'successSecondary'
| 'dangerSecondary'
| 'warnSecondary'
- | 'infoSecondary';
+ | 'infoSecondary'
+ | 'muted';
// the classes are typed in full because tailwind doesn't render the interpolated classes
const typeClasses: Record = {
@@ -54,6 +55,11 @@ const typeClasses: Record = {
'th-dark:text-blue-3 th-dark:bg-blue-9',
'th-highcontrast:text-blue-3 th-highcontrast:bg-blue-9'
),
+ muted: clsx(
+ 'text-gray-9 bg-gray-3',
+ 'th-dark:text-gray-3 th-dark:bg-gray-9',
+ 'th-highcontrast:text-gray-3 th-highcontrast:bg-gray-9'
+ ),
};
export interface Props {
diff --git a/app/react/components/Blocklist/BlocklistItem.stories.tsx b/app/react/components/Blocklist/BlocklistItem.stories.tsx
new file mode 100644
index 000000000..e38d1933e
--- /dev/null
+++ b/app/react/components/Blocklist/BlocklistItem.stories.tsx
@@ -0,0 +1,75 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { localizeDate } from '@/react/common/date-utils';
+
+import { Badge } from '@@/Badge';
+
+import { BlocklistItem } from './BlocklistItem';
+
+const meta: Meta = {
+ title: 'Components/Blocklist/BlocklistItem',
+ component: BlocklistItem,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ children: 'Default Blocklist Item',
+ },
+};
+
+export const Selected: Story = {
+ args: {
+ children: 'Selected Blocklist Item',
+ isSelected: true,
+ },
+};
+
+export const AsDiv: Story = {
+ args: {
+ children: 'Blocklist Item as div',
+ as: 'div',
+ },
+};
+
+export const WithCustomContent: Story = {
+ args: {
+ children: (
+
+
+ Deployed
+ Revision #4
+
+
+ my-app-1.0.0
+
+ {localizeDate(new Date('2000-01-01'))}
+
+
+
+ ),
+ },
+};
+
+export const MultipleItems: Story = {
+ render: () => (
+
+ First Item
+ Second Item (Selected)
+ Third Item
+
+ ),
+};
diff --git a/app/react/components/Card/Card.tsx b/app/react/components/Card/Card.tsx
index 1839f649e..b5e8bf5f1 100644
--- a/app/react/components/Card/Card.tsx
+++ b/app/react/components/Card/Card.tsx
@@ -10,7 +10,7 @@ export function Card({ className, children }: PropsWithChildren) {
{children}
diff --git a/app/react/components/CodeEditor.tsx b/app/react/components/CodeEditor.tsx
deleted file mode 100644
index e74a70ec9..000000000
--- a/app/react/components/CodeEditor.tsx
+++ /dev/null
@@ -1,228 +0,0 @@
-import CodeMirror, {
- keymap,
- oneDarkHighlightStyle,
-} from '@uiw/react-codemirror';
-import {
- StreamLanguage,
- LanguageSupport,
- syntaxHighlighting,
- indentService,
-} from '@codemirror/language';
-import { yaml } from '@codemirror/legacy-modes/mode/yaml';
-import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
-import { shell } from '@codemirror/legacy-modes/mode/shell';
-import { useCallback, useMemo, useState } from 'react';
-import { createTheme } from '@uiw/codemirror-themes';
-import { tags as highlightTags } from '@lezer/highlight';
-import type { JSONSchema7 } from 'json-schema';
-import { lintKeymap, lintGutter } from '@codemirror/lint';
-import { defaultKeymap } from '@codemirror/commands';
-import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
-import { yamlCompletion, yamlSchema } from 'yaml-schema';
-
-import { AutomationTestingProps } from '@/types';
-
-import { CopyButton } from '@@/buttons/CopyButton';
-
-import { useDebounce } from '../hooks/useDebounce';
-
-import styles from './CodeEditor.module.css';
-import { TextTip } from './Tip/TextTip';
-import { StackVersionSelector } from './StackVersionSelector';
-
-type Type = 'yaml' | 'shell' | 'dockerfile';
-interface Props extends AutomationTestingProps {
- id: string;
- placeholder?: string;
- type?: Type;
- readonly?: boolean;
- onChange?: (value: string) => void;
- value: string;
- height?: string;
- versions?: number[];
- onVersionChange?: (version: number) => void;
- schema?: JSONSchema7;
-}
-
-const theme = createTheme({
- theme: 'light',
- settings: {
- background: 'var(--bg-codemirror-color)',
- foreground: 'var(--text-codemirror-color)',
- caret: 'var(--border-codemirror-cursor-color)',
- selection: 'var(--bg-codemirror-selected-color)',
- selectionMatch: 'var(--bg-codemirror-selected-color)',
- gutterBackground: 'var(--bg-codemirror-gutters-color)',
- },
- styles: [
- { tag: highlightTags.atom, color: 'var(--text-cm-default-color)' },
- { tag: highlightTags.meta, color: 'var(--text-cm-meta-color)' },
- {
- tag: [highlightTags.string, highlightTags.special(highlightTags.brace)],
- color: 'var(--text-cm-string-color)',
- },
- { tag: highlightTags.number, color: 'var(--text-cm-number-color)' },
- { tag: highlightTags.keyword, color: 'var(--text-cm-keyword-color)' },
- { tag: highlightTags.comment, color: 'var(--text-cm-comment-color)' },
- {
- tag: highlightTags.variableName,
- color: 'var(--text-cm-variable-name-color)',
- },
- ],
-});
-
-// Custom indentation service for YAML
-const yamlIndentExtension = indentService.of((context, pos) => {
- const prevLine = context.lineAt(pos, -1);
-
- // Default to same as previous line
- const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
-
- // If previous line ends with a colon, increase indent
- if (/:\s*$/.test(prevLine.text)) {
- return prevIndent + 2; // Indent 2 spaces after a colon
- }
-
- return prevIndent;
-});
-
-// Create enhanced YAML language with custom indentation (from @codemirror/legacy-modes/mode/yaml)
-const yamlLanguageLegacy = new LanguageSupport(StreamLanguage.define(yaml), [
- yamlIndentExtension,
- syntaxHighlighting(oneDarkHighlightStyle),
-]);
-
-const dockerFileLanguage = new LanguageSupport(
- StreamLanguage.define(dockerFile)
-);
-const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
-
-const docTypeExtensionMap: Record
= {
- yaml: yamlLanguageLegacy,
- dockerfile: dockerFileLanguage,
- shell: shellLanguage,
-};
-
-function schemaValidationExtensions(schema: JSONSchema7) {
- // skip the hover extension because fields like 'networks' display as 'null' with no description when using the default hover
- // skip the completion extension in favor of custom completion
- const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
- return [
- yaml,
- linter,
- autocompletion({
- icons: false,
- activateOnTypingDelay: 300,
- selectOnOpen: true,
- activateOnTyping: true,
- override: [
- (ctx) => {
- const getCompletions = yamlCompletion();
- const completions = getCompletions(ctx);
- if (Array.isArray(completions)) {
- return null;
- }
-
- completions.validFor = /^\w*$/;
-
- return completions;
- },
- ],
- }),
- stateExtensions,
- yamlIndentExtension,
- syntaxHighlighting(oneDarkHighlightStyle),
- lintGutter(),
- keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
- ];
-}
-
-export function CodeEditor({
- id,
- onChange = () => {},
- placeholder,
- readonly,
- value,
- versions,
- onVersionChange,
- height = '500px',
- type,
- schema,
- 'data-cy': dataCy,
-}: Props) {
- const [isRollback, setIsRollback] = useState(false);
-
- const extensions = useMemo(() => {
- if (!type || !docTypeExtensionMap[type]) {
- return [];
- }
- // YAML-specific schema validation
- if (schema && type === 'yaml') {
- return schemaValidationExtensions(schema);
- }
- // Default language support
- return [docTypeExtensionMap[type]];
- }, [type, schema]);
-
- const handleVersionChange = useCallback(
- (version: number) => {
- if (versions && versions.length > 1) {
- setIsRollback(version < versions[0]);
- }
- onVersionChange?.(version);
- },
- [onVersionChange, versions]
- );
-
- const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
-
- return (
- <>
-
-
-
- {!!placeholder && {placeholder} }
-
-
-
-
- Copy to clipboard
-
-
-
- {versions && (
-
- )}
-
-
- >
- );
-}
diff --git a/app/react/components/CodeEditor.module.css b/app/react/components/CodeEditor/CodeEditor.module.css
similarity index 78%
rename from app/react/components/CodeEditor.module.css
rename to app/react/components/CodeEditor/CodeEditor.module.css
index 4936a56e4..2bf9cae88 100644
--- a/app/react/components/CodeEditor.module.css
+++ b/app/react/components/CodeEditor/CodeEditor.module.css
@@ -47,17 +47,33 @@
.root :global(.cm-editor .cm-gutters) {
border-right: 0px;
+ @apply bg-gray-2 th-dark:bg-gray-10 th-highcontrast:bg-black;
+}
+
+.root :global(.cm-merge-b) {
+ @apply border-0 border-l border-solid border-l-gray-5 th-dark:border-l-gray-7 th-highcontrast:border-l-gray-2;
}
.root :global(.cm-editor .cm-gutters .cm-lineNumbers .cm-gutterElement) {
text-align: left;
}
-.root :global(.cm-editor),
-.root :global(.cm-editor .cm-scroller) {
+.codeEditor :global(.cm-editor),
+.codeEditor :global(.cm-editor .cm-scroller) {
border-radius: 8px;
}
+/* code mirror merge side-by-side editor */
+.root :global(.cm-merge-a),
+.root :global(.cm-merge-a .cm-scroller) {
+ @apply !rounded-r-none;
+}
+
+.root :global(.cm-merge-b),
+.root :global(.cm-merge-b .cm-scroller) {
+ @apply !rounded-l-none;
+}
+
/* Search Panel */
/* Ideally we would use a react component for that, but this is the easy solution for onw */
@@ -162,3 +178,29 @@
.root :global(.cm-activeLineGutter) {
@apply bg-inherit;
}
+
+/* Collapsed lines gutter styles for all themes */
+.root :global(.cm-editor .cm-collapsedLines) {
+ /* inherit bg, instead of using styles from library */
+ background: inherit;
+ @apply bg-blue-2 th-dark:bg-blue-10 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
+}
+.root :global(.cm-editor .cm-collapsedLines):hover {
+ @apply bg-blue-3 th-dark:bg-blue-9 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
+}
+
+.root :global(.cm-editor .cm-collapsedLines:before) {
+ content: '↧ Expand all';
+ background: var(--bg-tooltip-color);
+ color: var(--text-tooltip-color);
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ white-space: nowrap;
+ z-index: 1000;
+ margin-left: 4px;
+}
+/* override the default content */
+.root :global(.cm-editor .cm-collapsedLines:after) {
+ content: '';
+}
diff --git a/app/react/components/CodeEditor.test.tsx b/app/react/components/CodeEditor/CodeEditor.test.tsx
similarity index 78%
rename from app/react/components/CodeEditor.test.tsx
rename to app/react/components/CodeEditor/CodeEditor.test.tsx
index 1d1a3ae85..7b100b0e3 100644
--- a/app/react/components/CodeEditor.test.tsx
+++ b/app/react/components/CodeEditor/CodeEditor.test.tsx
@@ -1,9 +1,21 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { Extension } from '@codemirror/state';
import { CodeEditor } from './CodeEditor';
-vi.mock('yaml-schema', () => ({}));
+const mockExtension: Extension = { extension: [] };
+vi.mock('yaml-schema', () => ({
+ // yamlSchema has 5 return values (all extensions)
+ yamlSchema: () => [
+ mockExtension,
+ mockExtension,
+ mockExtension,
+ mockExtension,
+ mockExtension,
+ ],
+ yamlCompletion: () => () => ({}),
+}));
const defaultProps = {
id: 'test-editor',
@@ -24,7 +36,7 @@ test('should render with basic props', () => {
test('should display placeholder when provided', async () => {
const placeholder = 'Enter your code here';
const { findByText } = render(
-
+
);
const placeholderText = await findByText(placeholder);
@@ -44,7 +56,7 @@ test('should show copy button and copy content', async () => {
clipboard: mockClipboard,
});
- const copyButton = await findByText('Copy to clipboard');
+ const copyButton = await findByText('Copy');
expect(copyButton).toBeVisible();
await userEvent.click(copyButton);
@@ -113,3 +125,14 @@ test('should apply custom height', async () => {
const editor = (await findByRole('textbox')).parentElement?.parentElement;
expect(editor).toHaveStyle({ height: customHeight });
});
+
+test('should render with file name header when provided', async () => {
+ const fileName = 'example.yaml';
+ const testValue = 'file content';
+ const { findByText } = render(
+
+ );
+
+ expect(await findByText(fileName)).toBeInTheDocument();
+ expect(await findByText(testValue)).toBeInTheDocument();
+});
diff --git a/app/react/components/CodeEditor/CodeEditor.tsx b/app/react/components/CodeEditor/CodeEditor.tsx
new file mode 100644
index 000000000..3df9f4193
--- /dev/null
+++ b/app/react/components/CodeEditor/CodeEditor.tsx
@@ -0,0 +1,158 @@
+import CodeMirror from '@uiw/react-codemirror';
+import { useCallback, useState } from 'react';
+import { createTheme } from '@uiw/codemirror-themes';
+import { tags as highlightTags } from '@lezer/highlight';
+import type { JSONSchema7 } from 'json-schema';
+import clsx from 'clsx';
+
+import { AutomationTestingProps } from '@/types';
+
+import { CopyButton } from '@@/buttons/CopyButton';
+
+import { useDebounce } from '../../hooks/useDebounce';
+import { TextTip } from '../Tip/TextTip';
+import { StackVersionSelector } from '../StackVersionSelector';
+
+import styles from './CodeEditor.module.css';
+import {
+ useCodeEditorExtensions,
+ CodeEditorType,
+} from './useCodeEditorExtensions';
+import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
+
+interface Props extends AutomationTestingProps {
+ id: string;
+ textTip?: string;
+ type?: CodeEditorType;
+ readonly?: boolean;
+ onChange?: (value: string) => void;
+ value: string;
+ height?: string;
+ versions?: number[];
+ onVersionChange?: (version: number) => void;
+ schema?: JSONSchema7;
+ fileName?: string;
+ placeholder?: string;
+}
+
+export const theme = createTheme({
+ theme: 'light',
+ settings: {
+ background: 'var(--bg-codemirror-color)',
+ foreground: 'var(--text-codemirror-color)',
+ caret: 'var(--border-codemirror-cursor-color)',
+ selection: 'var(--bg-codemirror-selected-color)',
+ selectionMatch: 'var(--bg-codemirror-selected-color)',
+ },
+ styles: [
+ { tag: highlightTags.atom, color: 'var(--text-cm-default-color)' },
+ { tag: highlightTags.meta, color: 'var(--text-cm-meta-color)' },
+ {
+ tag: [highlightTags.string, highlightTags.special(highlightTags.brace)],
+ color: 'var(--text-cm-string-color)',
+ },
+ { tag: highlightTags.number, color: 'var(--text-cm-number-color)' },
+ { tag: highlightTags.keyword, color: 'var(--text-cm-keyword-color)' },
+ { tag: highlightTags.comment, color: 'var(--text-cm-comment-color)' },
+ {
+ tag: highlightTags.variableName,
+ color: 'var(--text-cm-variable-name-color)',
+ },
+ ],
+});
+
+export function CodeEditor({
+ id,
+ onChange = () => {},
+ textTip,
+ readonly,
+ value,
+ versions,
+ onVersionChange,
+ height = '500px',
+ type,
+ schema,
+ 'data-cy': dataCy,
+ fileName,
+ placeholder,
+}: Props) {
+ const [isRollback, setIsRollback] = useState(false);
+
+ const extensions = useCodeEditorExtensions(type, schema);
+
+ const handleVersionChange = useCallback(
+ (version: number) => {
+ if (versions && versions.length > 1) {
+ setIsRollback(version < versions[0]);
+ }
+ onVersionChange?.(version);
+ },
+ [onVersionChange, versions]
+ );
+
+ const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
+
+ return (
+ <>
+
+
+
+ {!!textTip && {textTip} }
+
+ {/* the copy button is in the file name header, when fileName is provided */}
+ {!fileName && (
+
+
+ Copy
+
+
+ )}
+
+ {versions && (
+
+ )}
+
+
+ {fileName && (
+
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/app/react/components/CodeEditor/DiffViewer.test.tsx b/app/react/components/CodeEditor/DiffViewer.test.tsx
new file mode 100644
index 000000000..9e8b1b8ec
--- /dev/null
+++ b/app/react/components/CodeEditor/DiffViewer.test.tsx
@@ -0,0 +1,101 @@
+import { render } from '@testing-library/react';
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+
+import { DiffViewer } from './DiffViewer';
+
+// Mock CodeMirror
+vi.mock('@uiw/react-codemirror', () => ({
+ __esModule: true,
+ default: () =>
,
+ oneDarkHighlightStyle: {},
+ keymap: {
+ of: () => ({}),
+ },
+}));
+
+// Mock react-codemirror-merge
+vi.mock('react-codemirror-merge', () => {
+ function CodeMirrorMerge({ children }: { children: React.ReactNode }) {
+ return {children}
;
+ }
+ function Original({ value }: { value: string }) {
+ return {value}
;
+ }
+ function Modified({ value }: { value: string }) {
+ return {value}
;
+ }
+
+ CodeMirrorMerge.Original = Original;
+ CodeMirrorMerge.Modified = Modified;
+
+ return {
+ __esModule: true,
+ default: CodeMirrorMerge,
+ CodeMirrorMerge,
+ };
+});
+
+describe('DiffViewer', () => {
+ beforeEach(() => {
+ // Clear any mocks or state before each test
+ vi.clearAllMocks();
+ });
+
+ it('should render with basic props', () => {
+ const { getByText } = render(
+
+ );
+
+ // Check if the component renders with the expected content
+ expect(getByText('Original text')).toBeInTheDocument();
+ expect(getByText('New text')).toBeInTheDocument();
+ });
+
+ it('should render with file name headers when provided', () => {
+ const { getByText } = render(
+
+ );
+
+ // Look for elements with the expected class structure
+ const headerOriginal = getByText('Original File');
+ const headerModified = getByText('Modified File');
+ expect(headerOriginal).toBeInTheDocument();
+ expect(headerModified).toBeInTheDocument();
+ });
+
+ it('should apply custom height when provided', () => {
+ const customHeight = '800px';
+ const { container } = render(
+
+ );
+
+ // Find the element with the style containing the height
+ const divWithStyle = container.querySelector('[style*="height"]');
+ expect(divWithStyle).toBeInTheDocument();
+
+ // Check that the style contains the expected height
+ expect(divWithStyle?.getAttribute('style')).toContain(
+ `height: ${customHeight}`
+ );
+ });
+});
diff --git a/app/react/components/CodeEditor/DiffViewer.tsx b/app/react/components/CodeEditor/DiffViewer.tsx
new file mode 100644
index 000000000..9a66407af
--- /dev/null
+++ b/app/react/components/CodeEditor/DiffViewer.tsx
@@ -0,0 +1,138 @@
+import CodeMirrorMerge from 'react-codemirror-merge';
+import clsx from 'clsx';
+
+import { AutomationTestingProps } from '@/types';
+
+import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
+import styles from './CodeEditor.module.css';
+import {
+ CodeEditorType,
+ useCodeEditorExtensions,
+} from './useCodeEditorExtensions';
+import { theme } from './CodeEditor';
+
+const { Original } = CodeMirrorMerge;
+const { Modified } = CodeMirrorMerge;
+
+type Props = {
+ originalCode: string;
+ newCode: string;
+ id: string;
+ type?: CodeEditorType;
+ placeholder?: string;
+ height?: string;
+ fileNames?: {
+ original: string;
+ modified: string;
+ };
+ className?: string;
+} & AutomationTestingProps;
+
+const defaultCollapseUnchanged = {
+ margin: 10,
+ minSize: 10,
+};
+
+export function DiffViewer({
+ originalCode,
+ newCode,
+ id,
+ 'data-cy': dataCy,
+ type,
+ placeholder = 'No values found',
+
+ height = '500px',
+ fileNames,
+ className,
+}: Props) {
+ const extensions = useCodeEditorExtensions(type);
+ const hasFileNames = !!fileNames?.original && !!fileNames?.modified;
+ return (
+
+ {hasFileNames && (
+
+ )}
+ {/* additional div, so that the scroll gutter doesn't overlap with the rounded border, and always show scrollbar, so that the file name headers align */}
+
+ .cm-scroller]:!min-h-[var(--editor-min-height)]'
+ )}
+ id={id}
+ data-cy={dataCy}
+ collapseUnchanged={defaultCollapseUnchanged}
+ >
+
+
+
+
+
+ );
+}
+
+function DiffFileNameHeaders({
+ originalCopyText,
+ modifiedCopyText,
+ originalFileName,
+ modifiedFileName,
+}: {
+ originalCopyText: string;
+ modifiedCopyText: string;
+ originalFileName: string;
+ modifiedFileName: string;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/react/components/CodeEditor/FileNameHeader.tsx b/app/react/components/CodeEditor/FileNameHeader.tsx
new file mode 100644
index 000000000..eb3418f2a
--- /dev/null
+++ b/app/react/components/CodeEditor/FileNameHeader.tsx
@@ -0,0 +1,71 @@
+import clsx from 'clsx';
+
+import { AutomationTestingProps } from '@/types';
+
+import { CopyButton } from '@@/buttons/CopyButton';
+
+type FileNameHeaderProps = {
+ fileName: string;
+ copyText: string;
+ className?: string;
+ style?: React.CSSProperties;
+} & AutomationTestingProps;
+
+/**
+ * FileNameHeaderRow: Outer container for file name headers (single or multiple columns).
+ * Use this to wrap one or more components (and optional dividers).
+ */
+export function FileNameHeaderRow({
+ children,
+ className,
+ style,
+}: {
+ children: React.ReactNode;
+ className?: string;
+ style?: React.CSSProperties;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * FileNameHeader: Renders a file name with a copy button, styled for use above a code editor or diff viewer.
+ * Should be used inside FileNameHeaderRow.
+ */
+export function FileNameHeader({
+ fileName,
+ copyText,
+ className = '',
+ style,
+ 'data-cy': dataCy,
+}: FileNameHeaderProps) {
+ return (
+
+ {fileName}
+
+ Copy
+
+
+ );
+}
diff --git a/app/react/components/CodeEditor/index.ts b/app/react/components/CodeEditor/index.ts
new file mode 100644
index 000000000..aa4b20325
--- /dev/null
+++ b/app/react/components/CodeEditor/index.ts
@@ -0,0 +1 @@
+export * from './CodeEditor';
diff --git a/app/react/components/CodeEditor/useCodeEditorExtensions.ts b/app/react/components/CodeEditor/useCodeEditorExtensions.ts
new file mode 100644
index 000000000..3b46a543e
--- /dev/null
+++ b/app/react/components/CodeEditor/useCodeEditorExtensions.ts
@@ -0,0 +1,91 @@
+import { useMemo } from 'react';
+import {
+ StreamLanguage,
+ LanguageSupport,
+ syntaxHighlighting,
+ indentService,
+} from '@codemirror/language';
+import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
+import { shell } from '@codemirror/legacy-modes/mode/shell';
+import {
+ oneDarkHighlightStyle,
+ keymap,
+ Extension,
+} from '@uiw/react-codemirror';
+import type { JSONSchema7 } from 'json-schema';
+import { lintKeymap, lintGutter } from '@codemirror/lint';
+import { defaultKeymap } from '@codemirror/commands';
+import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
+import { yamlCompletion, yamlSchema } from 'yaml-schema';
+import { compact } from 'lodash';
+import { lineNumbers } from '@codemirror/view';
+
+export type CodeEditorType = 'yaml' | 'shell' | 'dockerfile';
+
+// Custom indentation service for YAML
+const yamlIndentExtension = indentService.of((context, pos) => {
+ const prevLine = context.lineAt(pos, -1);
+ const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
+ if (/:\s*$/.test(prevLine.text)) {
+ return prevIndent + 2;
+ }
+ return prevIndent;
+});
+
+const dockerFileLanguage = new LanguageSupport(
+ StreamLanguage.define(dockerFile)
+);
+const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
+
+function yamlLanguage(schema?: JSONSchema7) {
+ const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
+
+ return compact([
+ yaml,
+ linter,
+ stateExtensions,
+ yamlIndentExtension,
+ syntaxHighlighting(oneDarkHighlightStyle),
+ // explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor
+ lineNumbers(),
+ lintGutter(),
+ keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
+ // only show completions when a schema is provided
+ !!schema &&
+ autocompletion({
+ icons: false,
+ activateOnTypingDelay: 300,
+ selectOnOpen: true,
+ activateOnTyping: true,
+ override: [
+ (ctx) => {
+ const getCompletions = yamlCompletion();
+ const completions = getCompletions(ctx);
+ if (Array.isArray(completions)) {
+ return null;
+ }
+ completions.validFor = /^\w*$/;
+ return completions;
+ },
+ ],
+ }),
+ ]);
+}
+
+export function useCodeEditorExtensions(
+ type?: CodeEditorType,
+ schema?: JSONSchema7
+): Extension[] {
+ return useMemo(() => {
+ switch (type) {
+ case 'dockerfile':
+ return [dockerFileLanguage];
+ case 'shell':
+ return [shellLanguage];
+ case 'yaml':
+ return yamlLanguage(schema);
+ default:
+ return [];
+ }
+ }, [type, schema]);
+}
diff --git a/app/react/components/InlineLoader/InlineLoader.tsx b/app/react/components/InlineLoader/InlineLoader.tsx
index b5d75eae6..d91354de7 100644
--- a/app/react/components/InlineLoader/InlineLoader.tsx
+++ b/app/react/components/InlineLoader/InlineLoader.tsx
@@ -13,21 +13,21 @@ export type Props = {
};
const sizeStyles: Record = {
- xs: 'text-xs',
- sm: 'text-sm',
- md: 'text-md',
+ xs: 'text-xs gap-1',
+ sm: 'text-sm gap-2',
+ md: 'text-md gap-2',
};
export function InlineLoader({ children, className, size = 'sm' }: Props) {
return (
-
+
{children}
);
diff --git a/app/react/components/RadioGroup/RadioGroup.tsx b/app/react/components/RadioGroup/RadioGroup.tsx
index 4380d1d9c..5aacf03c0 100644
--- a/app/react/components/RadioGroup/RadioGroup.tsx
+++ b/app/react/components/RadioGroup/RadioGroup.tsx
@@ -1,10 +1,19 @@
-import { Option } from '@@/form-components/PortainerSelect';
+import { ReactNode } from 'react';
+
+// allow custom labels
+export interface RadioGroupOption {
+ value: TValue;
+ label: ReactNode;
+ disabled?: boolean;
+}
interface Props {
- options: Array> | ReadonlyArray >;
+ options: Array> | ReadonlyArray>;
selectedOption: T;
name: string;
onOptionChange: (value: T) => void;
+ groupClassName?: string;
+ itemClassName?: string;
}
export function RadioGroup({
@@ -12,13 +21,18 @@ export function RadioGroup({
selectedOption,
name,
onOptionChange,
+ groupClassName,
+ itemClassName,
}: Props) {
return (
-
+
{options.map((option) => (
({
onChange={() => onOptionChange(option.value)}
style={{ margin: '0 4px 0 0' }}
data-cy={`radio-${option.value}`}
+ disabled={option.disabled}
/>
{option.label}
diff --git a/app/react/components/Sheet.tsx b/app/react/components/Sheet.tsx
new file mode 100644
index 000000000..3d5983877
--- /dev/null
+++ b/app/react/components/Sheet.tsx
@@ -0,0 +1,159 @@
+import {
+ ComponentPropsWithoutRef,
+ forwardRef,
+ ElementRef,
+ PropsWithChildren,
+} from 'react';
+import * as SheetPrimitive from '@radix-ui/react-dialog';
+import { cva, type VariantProps } from 'class-variance-authority';
+import clsx from 'clsx';
+import { RefreshCw, X } from 'lucide-react';
+
+import { Button } from './buttons';
+
+// modified from shadcn sheet component
+const Sheet = SheetPrimitive.Root;
+
+const SheetTrigger = SheetPrimitive.Trigger;
+
+const SheetClose = SheetPrimitive.Close;
+
+const SheetPortal = SheetPrimitive.Portal;
+
+const SheetDescription = SheetPrimitive.Description;
+
+type SheetTitleProps = {
+ title: string;
+ onReload?(): Promise
| void;
+};
+
+// similar to the PageHeader component with simplified props and no breadcrumbs
+function SheetHeader({
+ onReload,
+ title,
+ children,
+}: PropsWithChildren) {
+ return (
+
+
+
+
+ {title}
+
+ {onReload ? (
+
+
+
+ ) : null}
+
+ {children}
+
+
+ );
+}
+
+const SheetOverlay = forwardRef<
+ ElementRef,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ 'fixed gap-4 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
+ {
+ variants: {
+ side: {
+ top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
+ bottom:
+ 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
+ left: 'inset-y-0 left-0 h-full w-[70vw] lg:w-[50vw] border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left max-w-2xl',
+ right:
+ 'inset-y-0 right-0 h-full w-[70vw] lg:w-[50vw] border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right max-w-2xl',
+ },
+ },
+ defaultVariants: {
+ side: 'right',
+ },
+ }
+);
+
+interface SheetContentProps
+ extends ComponentPropsWithoutRef,
+ VariantProps {
+ showCloseButton?: boolean;
+}
+
+const SheetContent = forwardRef<
+ ElementRef,
+ SheetContentProps
+>(
+ (
+ {
+ side = 'right',
+ className,
+ children,
+ title,
+ showCloseButton = true,
+ ...props
+ },
+ ref
+ ) => (
+
+
+
+ {title ? : null}
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+
+ )}
+
+
+ )
+);
+SheetContent.displayName = SheetPrimitive.Content.displayName;
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+};
diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx
index 222a4552f..75031a454 100644
--- a/app/react/components/WebEditorForm.tsx
+++ b/app/react/components/WebEditorForm.tsx
@@ -74,6 +74,7 @@ export function WebEditorForm({
children,
error,
schema,
+ textTip,
...props
}: PropsWithChildren) {
return (
@@ -99,6 +100,7 @@ export function WebEditorForm({
id={id}
type="yaml"
schema={schema as JSONSchema7}
+ textTip={textTip}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
diff --git a/app/react/components/Widget/WidgetIcon.tsx b/app/react/components/Widget/WidgetIcon.tsx
new file mode 100644
index 000000000..0309d185e
--- /dev/null
+++ b/app/react/components/Widget/WidgetIcon.tsx
@@ -0,0 +1,11 @@
+import { ReactNode } from 'react';
+
+import { Icon } from '@@/Icon';
+
+export function WidgetIcon({ icon }: { icon: ReactNode }) {
+ return (
+
+
+
+ );
+}
diff --git a/app/react/components/Widget/WidgetTitle.tsx b/app/react/components/Widget/WidgetTitle.tsx
index 2623c374c..3ed390e33 100644
--- a/app/react/components/Widget/WidgetTitle.tsx
+++ b/app/react/components/Widget/WidgetTitle.tsx
@@ -1,9 +1,7 @@
import clsx from 'clsx';
import { PropsWithChildren, ReactNode } from 'react';
-import { Icon } from '@/react/components/Icon';
-
-import { useWidgetContext } from './Widget';
+import { WidgetIcon } from './WidgetIcon';
interface Props {
title: ReactNode;
@@ -17,16 +15,12 @@ export function WidgetTitle({
className,
children,
}: PropsWithChildren) {
- useWidgetContext();
-
return (
-
-
-
- {title}
+
+ {title}
{children}
diff --git a/app/react/components/buttons/CopyButton/CopyButton.stories.tsx b/app/react/components/buttons/CopyButton/CopyButton.stories.tsx
index 42368f9b0..2a3df593b 100644
--- a/app/react/components/buttons/CopyButton/CopyButton.stories.tsx
+++ b/app/react/components/buttons/CopyButton/CopyButton.stories.tsx
@@ -26,13 +26,13 @@ function Template({
export const Primary: Story
> = Template.bind({});
Primary.args = {
- children: 'Copy to clipboard',
+ children: 'Copy',
copyText: 'this will be copied to clipboard',
};
export const NoCopyText: Story> = Template.bind({});
NoCopyText.args = {
- children: 'Copy to clipboard without copied text',
+ children: 'Copy without copied text',
copyText: 'clipboard override',
displayText: '',
};
diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx
index 2d0b2dfa8..dff56aaf6 100644
--- a/app/react/components/datatables/Datatable.tsx
+++ b/app/react/components/datatables/Datatable.tsx
@@ -58,7 +58,7 @@ export interface Props extends AutomationTestingProps {
getRowId?(row: D): string;
isRowSelectable?(row: Row): boolean;
emptyContentLabel?: string;
- title?: string;
+ title?: React.ReactNode;
titleIcon?: IconProps['icon'];
titleId?: string;
initialTableState?: Partial;
@@ -71,6 +71,8 @@ export interface Props extends AutomationTestingProps {
noWidget?: boolean;
extendTableOptions?: (options: TableOptions) => TableOptions;
includeSearch?: boolean;
+ ariaLabel?: string;
+ id?: string;
}
export function Datatable({
@@ -100,6 +102,8 @@ export function Datatable({
isServerSidePagination = false,
extendTableOptions = (value) => value,
includeSearch,
+ ariaLabel,
+ id,
}: Props & PaginationProps) {
const pageCount = useMemo(
() => Math.ceil(totalCount / settings.pageSize),
@@ -181,9 +185,14 @@ export function Datatable({
() => _.difference(selectedItems, filteredItems),
[selectedItems, filteredItems]
);
+ const { titleAriaLabel, contentAriaLabel } = getAriaLabels(
+ ariaLabel,
+ title,
+ titleId
+ );
return (
-
+
({
isLoading={isLoading}
onSortChange={handleSortChange}
data-cy={dataCy}
- aria-label={`${title} table`}
+ aria-label={contentAriaLabel}
/>
({
}
}
+function getAriaLabels(
+ titleAriaLabel?: string,
+ title?: ReactNode,
+ titleId?: string
+) {
+ if (titleAriaLabel) {
+ return { titleAriaLabel, contentAriaLabel: `${titleAriaLabel} table` };
+ }
+ if (typeof title === 'string') {
+ return { titleAriaLabel: title, contentAriaLabel: `${title} table` };
+ }
+ if (titleId) {
+ return { titleAriaLabel: titleId, contentAriaLabel: `${titleId} table` };
+ }
+ return { titleAriaLabel: 'table', contentAriaLabel: 'table' };
+}
+
function defaultRenderRow(
row: Row,
highlightedItemId?: string
diff --git a/app/react/components/datatables/DatatableHeader.tsx b/app/react/components/datatables/DatatableHeader.tsx
index 810f13c7a..bc0f56763 100644
--- a/app/react/components/datatables/DatatableHeader.tsx
+++ b/app/react/components/datatables/DatatableHeader.tsx
@@ -8,7 +8,7 @@ import { SearchBar } from './SearchBar';
import { Table } from './Table';
type Props = {
- title?: string;
+ title?: React.ReactNode;
titleIcon?: IconProps['icon'];
searchValue: string;
onSearchChange(value: string): void;
@@ -52,7 +52,7 @@ export function DatatableHeader({
return (
) {
if (noWidget) {
return (
@@ -25,7 +27,7 @@ export function TableContainer({
-
+
{children}
diff --git a/app/react/components/datatables/TableTitle.tsx b/app/react/components/datatables/TableTitle.tsx
index e253a8b66..feaf529bb 100644
--- a/app/react/components/datatables/TableTitle.tsx
+++ b/app/react/components/datatables/TableTitle.tsx
@@ -5,7 +5,7 @@ import { Icon } from '@@/Icon';
interface Props {
icon?: ReactNode | ComponentType
;
- label: string;
+ label: React.ReactNode;
description?: ReactNode;
className?: string;
id?: string;
diff --git a/app/react/components/form-components/EnvironmentVariablesFieldset/AdvancedMode.tsx b/app/react/components/form-components/EnvironmentVariablesFieldset/AdvancedMode.tsx
index 7deac19ee..54bc3e691 100644
--- a/app/react/components/form-components/EnvironmentVariablesFieldset/AdvancedMode.tsx
+++ b/app/react/components/form-components/EnvironmentVariablesFieldset/AdvancedMode.tsx
@@ -43,7 +43,7 @@ export function AdvancedMode({
id="environment-variables-editor"
value={editorValue}
onChange={handleEditorChange}
- placeholder="e.g. key=value"
+ textTip="e.g. key=value"
data-cy={dataCy}
/>
>
diff --git a/app/react/components/modals/Modal/Modal.module.css b/app/react/components/modals/Modal/Modal.module.css
index f6ab6bb4b..73bf935d7 100644
--- a/app/react/components/modals/Modal/Modal.module.css
+++ b/app/react/components/modals/Modal/Modal.module.css
@@ -10,14 +10,8 @@
.modal-content {
background-color: var(--bg-modal-content-color);
- padding: 20px;
-
- position: relative;
border: 1px solid rgba(0, 0, 0, 0.2);
- border-radius: 6px;
-
- outline: 0;
box-shadow: 0 5px 15px rgb(0 0 0 / 50%);
}
diff --git a/app/react/components/modals/Modal/Modal.tsx b/app/react/components/modals/Modal/Modal.tsx
index 1f2cb729b..0d1b0319c 100644
--- a/app/react/components/modals/Modal/Modal.tsx
+++ b/app/react/components/modals/Modal/Modal.tsx
@@ -39,7 +39,7 @@ export function Modal({
isOpen
className={clsx(
styles.overlay,
- 'z-50 flex items-center justify-center'
+ 'flex items-center justify-center z-50'
)}
onDismiss={onDismiss}
role="dialog"
@@ -56,7 +56,13 @@ export function Modal({
}
)}
>
-
+
{children}
{onDismiss && }
diff --git a/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx b/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx
index 3fad4a8da..e871d197d 100644
--- a/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx
+++ b/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx
@@ -86,7 +86,7 @@ function InnerForm({ isLoading }: { isLoading: boolean }) {
id="edge-job-editor"
onChange={(value) => setFieldValue('fileContent', value)}
value={values.fileContent}
- placeholder="Define or paste the content of your script file here"
+ textTip="Define or paste the content of your script file here"
type="shell"
error={errors.fileContent}
/>
diff --git a/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/UpdateEdgeJobForm.tsx b/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/UpdateEdgeJobForm.tsx
index e25684b97..54ae5dc60 100644
--- a/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/UpdateEdgeJobForm.tsx
+++ b/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/UpdateEdgeJobForm.tsx
@@ -82,7 +82,7 @@ function InnerForm({ isLoading }: { isLoading: boolean }) {
id="edge-job-editor"
onChange={(value) => setFieldValue('fileContent', value)}
value={values.fileContent}
- placeholder="Define or paste the content of your script file here"
+ textTip="Define or paste the content of your script file here"
type="shell"
error={errors.fileContent}
/>
diff --git a/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx b/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx
index 2f24354bb..7758b353a 100644
--- a/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx
+++ b/app/react/edge/edge-stacks/CreateView/DockerContentField.tsx
@@ -28,7 +28,7 @@ export function DockerContentField({
value={value}
onChange={onChange}
type="yaml"
- placeholder="Define or paste the content of your docker compose file here"
+ textTip="Define or paste the content of your docker compose file here"
error={error}
readonly={readonly}
schema={dockerComposeSchemaQuery.data}
diff --git a/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx b/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx
index 1c516d86e..4b2d3504f 100644
--- a/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx
+++ b/app/react/edge/edge-stacks/CreateView/KubeManifestForm.tsx
@@ -74,7 +74,7 @@ export function KubeManifestForm({
value={values.fileContent}
onChange={(value) => handleChange({ fileContent: value })}
type="yaml"
- placeholder="Define or paste the content of your manifest file here"
+ textTip="Define or paste the content of your manifest file here"
error={errors?.fileContent}
data-cy="stack-creation-editor"
>
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx
index 806d793ab..8d3fb22d8 100644
--- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx
@@ -66,7 +66,7 @@ export function ComposeForm({
type="yaml"
schema={dockerComposeSchema}
id="compose-editor"
- placeholder="Define or paste the content of your docker compose file here"
+ textTip="Define or paste the content of your docker compose file here"
onChange={(value) => handleContentChange(DeploymentType.Compose, value)}
error={errors.content}
readonly={hasKubeEndpoint}
diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx
index e84acade5..a493ef131 100644
--- a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx
+++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx
@@ -37,7 +37,7 @@ export function KubernetesForm({
value={values.content}
type="yaml"
id="kube-manifest-editor"
- placeholder="Define or paste the content of your manifest here"
+ textTip="Define or paste the content of your manifest here"
onChange={(value) =>
handleContentChange(DeploymentType.Kubernetes, value)
}
diff --git a/app/react/hooks/useDebounce.ts b/app/react/hooks/useDebounce.ts
index 271b0158c..4125df8ff 100644
--- a/app/react/hooks/useDebounce.ts
+++ b/app/react/hooks/useDebounce.ts
@@ -34,19 +34,23 @@ import { useState, useRef, useCallback, useEffect } from 'react';
//
// return (
handleChange(e.target.value)} />)
// }
-export function useDebounce(value: string, onChange: (value: string) => void) {
+export function useDebounce
(
+ value: T,
+ onChange: (value: T) => void,
+ delay = 300
+) {
const [debouncedValue, setDebouncedValue] = useState(value);
// Do not change. See notes above
const onChangeDebouncer = useRef(
debounce(
- (value: string, onChangeFunc: (v: string) => void) => onChangeFunc(value),
- 300
+ (value: T, onChangeFunc: (v: T) => void) => onChangeFunc(value),
+ delay
)
);
const handleChange = useCallback(
- (value: string) => {
+ (value: T) => {
setDebouncedValue(value);
onChangeDebouncer.current(value, onChange);
},
diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx
index f578dc355..8f4289f74 100644
--- a/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx
+++ b/app/react/kubernetes/applications/DetailsView/ApplicationEventsDatatable.tsx
@@ -1,5 +1,6 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { useMemo } from 'react';
+import { compact } from 'lodash';
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
import { EnvironmentId } from '@/react/portainer/environments/types';
@@ -74,36 +75,32 @@ export function useApplicationEvents(
application
);
+ // related events are events that have the application id, or the id of a service or pod from the application
+ const relatedUids = useMemo(() => {
+ const serviceIds = compact(
+ servicesQuery.data?.map((service) => service?.metadata?.uid)
+ );
+ const podIds = compact(podsQuery.data?.map((pod) => pod?.metadata?.uid));
+ return [application?.metadata?.uid, ...serviceIds, ...podIds];
+ }, [application?.metadata?.uid, podsQuery.data, servicesQuery.data]);
+
+ const relatedUidsSet = useMemo(() => new Set(relatedUids), [relatedUids]);
const { data: events, ...eventsQuery } = useEvents(environmentId, {
namespace,
queryOptions: {
autoRefreshRate: options?.autoRefreshRate
? options.autoRefreshRate * 1000
: undefined,
+ select: (data) =>
+ data.filter((event) => relatedUidsSet.has(event.involvedObject.uid)),
},
});
- // related events are events that have the application id, or the id of a service or pod from the application
- const relatedEvents = useMemo(() => {
- const serviceIds = servicesQuery.data?.map(
- (service) => service?.metadata?.uid
- );
- const podIds = podsQuery.data?.map((pod) => pod?.metadata?.uid);
- return (
- events?.filter(
- (event) =>
- event.involvedObject.uid === application?.metadata?.uid ||
- serviceIds?.includes(event.involvedObject.uid) ||
- podIds?.includes(event.involvedObject.uid)
- ) || []
- );
- }, [application?.metadata?.uid, events, podsQuery.data, servicesQuery.data]);
-
const isInitialLoading =
applicationQuery.isInitialLoading ||
servicesQuery.isInitialLoading ||
podsQuery.isInitialLoading ||
eventsQuery.isInitialLoading;
- return { relatedEvents, isInitialLoading };
+ return { relatedEvents: events || [], isInitialLoading };
}
diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx
index 4e6712939..091e2f97e 100644
--- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx
+++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable.tsx
@@ -96,6 +96,7 @@ export function ApplicationsDatatable({
item={row.original}
hideStacks={hideStacks}
areSecretsRestricted={!!restrictSecretsQuery.data}
+ selectDisabled={!hasWriteAuth}
/>
)}
renderTableActions={(selectedItems) =>
diff --git a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx
index ce4b5d4ec..6a32865b0 100644
--- a/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx
+++ b/app/react/kubernetes/applications/ListView/ApplicationsDatatable/SubRow.tsx
@@ -11,19 +11,22 @@ export function SubRow({
item,
hideStacks,
areSecretsRestricted,
+ selectDisabled,
}: {
item: ApplicationRowData;
hideStacks: boolean;
areSecretsRestricted: boolean;
+ selectDisabled: boolean;
}) {
const {
user: { Username: username },
} = useCurrentUser();
const colSpan = hideStacks ? 7 : 8;
+ const alignColSpan = selectDisabled ? 1 : 2;
return (
-
+
{item.KubernetesApplications ? (
}
onChange={(values) => onChange(values)}
id={formId}
- placeholder="Define or paste the content of your manifest file here"
+ textTip="Define or paste the content of your manifest file here"
type="yaml"
/>
diff --git a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx
index d2f705c5f..ffb92e7bd 100644
--- a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx
+++ b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx
@@ -1,5 +1,6 @@
import { Event } from 'kubernetes-types/core/v1';
import { History } from 'lucide-react';
+import { ReactNode } from 'react';
import { IndexOptional } from '@/react/kubernetes/configs/types';
import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
@@ -16,6 +17,8 @@ type Props = {
isLoading: boolean;
'data-cy': string;
noWidget?: boolean;
+ title?: ReactNode;
+ titleIcon?: ReactNode;
};
export function EventsDatatable({
@@ -24,6 +27,8 @@ export function EventsDatatable({
isLoading,
'data-cy': dataCy,
noWidget,
+ title = 'Events',
+ titleIcon = History,
}: Props) {
return (
>
@@ -31,8 +36,8 @@ export function EventsDatatable({
columns={columns}
settingsManager={tableState}
isLoading={isLoading}
- title="Events"
- titleIcon={History}
+ title={title}
+ titleIcon={titleIcon}
getRowId={(row) => row.metadata?.uid || ''}
disableSelect
renderTableSettings={() => (
diff --git a/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx b/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx
index 47b15a544..0d381d682 100644
--- a/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx
+++ b/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx
@@ -1,4 +1,8 @@
+import { Row } from '@tanstack/react-table';
+import { Event } from 'kubernetes-types/core/v1';
+
import { Badge, BadgeType } from '@@/Badge';
+import { filterHOC } from '@@/datatables/Filter';
import { columnHelper } from './helper';
@@ -7,6 +11,14 @@ export const eventType = columnHelper.accessor('type', {
cell: ({ getValue }) => (
{getValue()}
),
+
+ meta: {
+ filter: filterHOC('Filter by event type'),
+ },
+ enableColumnFilter: true,
+ filterFn: (row: Row, _: string, filterValue: string[]) =>
+ filterValue.length === 0 ||
+ (!!row.original.type && filterValue.includes(row.original.type)),
});
function getBadgeColor(status?: string): BadgeType {
diff --git a/app/react/kubernetes/components/EventsDatatable/columns/index.ts b/app/react/kubernetes/components/EventsDatatable/columns/index.ts
index 05ae483d5..f49658090 100644
--- a/app/react/kubernetes/components/EventsDatatable/columns/index.ts
+++ b/app/react/kubernetes/components/EventsDatatable/columns/index.ts
@@ -2,5 +2,6 @@ import { date } from './date';
import { kind } from './kind';
import { eventType } from './eventType';
import { message } from './message';
+import { name } from './name';
-export const columns = [date, kind, eventType, message];
+export const columns = [date, name, kind, eventType, message];
diff --git a/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx b/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx
index 856d6bf8b..641662291 100644
--- a/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx
+++ b/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx
@@ -1,8 +1,21 @@
+import { Row } from '@tanstack/react-table';
+import { Event } from 'kubernetes-types/core/v1';
+
+import { filterHOC } from '@@/datatables/Filter';
+
import { columnHelper } from './helper';
export const kind = columnHelper.accessor(
(event) => event.involvedObject.kind,
{
header: 'Kind',
+ meta: {
+ filter: filterHOC('Filter by kind'),
+ },
+ enableColumnFilter: true,
+ filterFn: (row: Row, _: string, filterValue: string[]) =>
+ filterValue.length === 0 ||
+ (!!row.original.involvedObject.kind &&
+ filterValue.includes(row.original.involvedObject.kind)),
}
);
diff --git a/app/react/kubernetes/components/EventsDatatable/columns/name.tsx b/app/react/kubernetes/components/EventsDatatable/columns/name.tsx
new file mode 100644
index 000000000..26bc0c8db
--- /dev/null
+++ b/app/react/kubernetes/components/EventsDatatable/columns/name.tsx
@@ -0,0 +1,16 @@
+import { columnHelper } from './helper';
+
+export const name = columnHelper.accessor(
+ (event) => event.involvedObject.name ?? '-',
+ {
+ header: 'Name',
+ cell: ({ getValue }) => {
+ const name = getValue();
+ return (
+
+ {name}
+
+ );
+ },
+ }
+);
diff --git a/app/react/kubernetes/components/YAMLInspector.tsx b/app/react/kubernetes/components/YAMLInspector.tsx
index 5baadf3a7..9e678e9f5 100644
--- a/app/react/kubernetes/components/YAMLInspector.tsx
+++ b/app/react/kubernetes/components/YAMLInspector.tsx
@@ -29,7 +29,7 @@ export function YAMLInspector({
void;
+};
export function ChartActions({
environmentId,
releaseName,
namespace,
- currentRevision,
-}: {
- environmentId: EnvironmentId;
- releaseName: string;
- namespace?: string;
- currentRevision?: number;
-}) {
- const hasPreviousRevision = currentRevision && currentRevision >= 2;
+ latestRevision,
+ earlistRevision,
+ selectedRevision,
+ release,
+ updateRelease,
+}: Props) {
+ const showRollbackButton =
+ latestRevision && earlistRevision && latestRevision > earlistRevision;
return (
-
+
+
- {hasPreviousRevision && (
+ {showRollbackButton && (
({
function renderButton(props = {}) {
const defaultProps = {
latestRevision: 3, // So we're rolling back to revision 2
+ selectedRevision: 3, // This simulates the selectedRevision from URL params
environmentId: 1,
releaseName: 'test-release',
namespace: 'default',
...props,
};
- const Wrapped = withTestQueryProvider(RollbackButton);
+ const Wrapped = withTestQueryProvider(
+ withTestRouter(RollbackButton, {
+ route: '/?revision=3',
+ })
+ );
return render( );
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx
index 3d870d3a8..ea8a79bac 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/RollbackButton.tsx
@@ -1,4 +1,5 @@
import { RotateCcw } from 'lucide-react';
+import { useRouter } from '@uirouter/react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
@@ -12,6 +13,7 @@ import { useHelmRollbackMutation } from '../queries/useHelmRollbackMutation';
type Props = {
latestRevision: number;
+ selectedRevision?: number;
environmentId: EnvironmentId;
releaseName: string;
namespace?: string;
@@ -19,13 +21,16 @@ type Props = {
export function RollbackButton({
latestRevision,
+ selectedRevision,
environmentId,
releaseName,
namespace,
}: Props) {
- // the selectedRevision can be a prop when selecting a revision is implemented
- const selectedRevision = latestRevision ? latestRevision - 1 : undefined;
-
+ // when the latest revision is selected, rollback to the previous revision
+ // otherwise, rollback to the selected revision
+ const rollbackRevision =
+ selectedRevision === latestRevision ? latestRevision - 1 : selectedRevision;
+ const router = useRouter();
const rollbackMutation = useHelmRollbackMutation(environmentId);
return (
@@ -38,7 +43,7 @@ export function RollbackButton({
color="default"
size="medium"
>
- Rollback to #{selectedRevision}
+ Rollback to #{rollbackRevision}
);
@@ -47,7 +52,7 @@ export function RollbackButton({
title: 'Are you sure?',
modalType: ModalType.Warn,
confirmButton: buildConfirmButton('Rollback'),
- message: `Rolling back will restore the application to revision #${selectedRevision}, which will cause service interruption. Do you wish to continue?`,
+ message: `Rolling back will restore the application to revision #${rollbackRevision}, which could cause service interruption. Do you wish to continue?`,
});
if (!confirmed) {
return;
@@ -56,14 +61,20 @@ export function RollbackButton({
rollbackMutation.mutate(
{
releaseName,
- params: { namespace, revision: selectedRevision },
+ params: { namespace, revision: rollbackRevision },
},
{
onSuccess: () => {
notifySuccess(
'Success',
- `Application rolled back to revision #${selectedRevision} successfully.`
+ `Application rolled back to revision #${rollbackRevision} successfully.`
);
+ // set the revision url param to undefined to refresh the page at the latest revision
+ router.stateService.go('kubernetes.helm', {
+ namespace,
+ name: releaseName,
+ revision: undefined,
+ });
},
}
);
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx
new file mode 100644
index 000000000..4bea0bf47
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx
@@ -0,0 +1,182 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi } from 'vitest';
+
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+import { withTestRouter } from '@/react/test-utils/withRouter';
+
+import {
+ useHelmRepoVersions,
+ ChartVersion,
+} from '../queries/useHelmRepositories';
+import { HelmRelease } from '../../types';
+
+import { openUpgradeHelmModal } from './UpgradeHelmModal';
+import { UpgradeButton } from './UpgradeButton';
+
+// Mock the upgrade modal function
+vi.mock('./UpgradeHelmModal', () => ({
+ openUpgradeHelmModal: vi.fn(() => Promise.resolve(undefined)),
+}));
+
+// Mock the notifications service
+vi.mock('@/portainer/services/notifications', () => ({
+ notifySuccess: vi.fn(),
+}));
+
+// Mock the useHelmRepoVersions and useHelmRepositories hooks
+vi.mock('../queries/useHelmRepositories', () => ({
+ useHelmRepoVersions: vi.fn(() => ({
+ data: [
+ { Version: '1.0.0', Repo: 'stable' },
+ { Version: '1.1.0', Repo: 'stable' },
+ ],
+ isInitialLoading: false,
+ isError: false,
+ })),
+ useHelmRepositories: vi.fn(() => ({
+ data: ['repo1', 'repo2'],
+ isInitialLoading: false,
+ isError: false,
+ })),
+}));
+
+function renderButton(props = {}) {
+ const defaultProps = {
+ environmentId: 1,
+ releaseName: 'test-release',
+ namespace: 'default',
+ release: {
+ name: 'test-release',
+ chart: {
+ metadata: {
+ name: 'test-chart',
+ version: '1.0.0',
+ },
+ },
+ values: {
+ userSuppliedValues: '{}',
+ },
+ manifest: '',
+ } as HelmRelease,
+ updateRelease: vi.fn(),
+ ...props,
+ };
+
+ const Wrapped = withTestQueryProvider(withTestRouter(UpgradeButton));
+ return render( );
+}
+
+describe('UpgradeButton', () => {
+ test('should display the upgrade button', () => {
+ renderButton();
+
+ const button = screen.getByRole('button', { name: /Upgrade/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ test('should be disabled when no versions are available', () => {
+ const data: ChartVersion[] = [];
+ vi.mocked(useHelmRepoVersions).mockReturnValue({
+ data,
+ isInitialLoading: false,
+ isError: false,
+ });
+
+ renderButton();
+
+ const button = screen.getByRole('button', { name: /Upgrade/i });
+ expect(button).toBeDisabled();
+ });
+
+ test('should show loading state when checking for versions', () => {
+ vi.mocked(useHelmRepoVersions).mockReturnValue({
+ data: [],
+ isInitialLoading: true,
+ isError: false,
+ });
+
+ renderButton();
+
+ expect(
+ screen.getByText('Checking for new versions...')
+ ).toBeInTheDocument();
+ });
+
+ test('should show "No versions available" when no versions are found', () => {
+ const data: ChartVersion[] = [];
+ vi.mocked(useHelmRepoVersions).mockReturnValue({
+ data,
+ isInitialLoading: false,
+ isError: false,
+ });
+
+ renderButton();
+
+ expect(screen.getByText('No versions available')).toBeInTheDocument();
+ });
+
+ test('should open upgrade modal when clicked', async () => {
+ const user = userEvent.setup();
+ const mockRelease = {
+ name: 'test-release',
+ chart: {
+ metadata: {
+ name: 'test-chart',
+ version: '1.0.0',
+ },
+ },
+ values: {
+ userSuppliedValues: '{}',
+ },
+ manifest: '',
+ } as HelmRelease;
+
+ vi.mocked(useHelmRepoVersions).mockReturnValue({
+ data: [
+ { Version: '1.0.0', Repo: 'stable' },
+ { Version: '1.1.0', Repo: 'stable' },
+ ],
+ isInitialLoading: false,
+ isError: false,
+ });
+
+ renderButton({ release: mockRelease });
+
+ const button = screen.getByRole('button', { name: /Upgrade/i });
+ await user.click(button);
+
+ await waitFor(() => {
+ expect(openUpgradeHelmModal).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'test-release',
+ chart: 'test-chart',
+ namespace: 'default',
+ values: '{}',
+ version: '1.0.0',
+ }),
+ expect.arrayContaining([
+ { Version: '1.0.0', Repo: 'stable' },
+ { Version: '1.1.0', Repo: 'stable' },
+ ])
+ );
+ });
+ });
+
+ test('should not execute the upgrade if modal is cancelled', async () => {
+ const mockUpdateRelease = vi.fn();
+ vi.mocked(openUpgradeHelmModal).mockResolvedValueOnce(undefined);
+
+ const user = userEvent.setup();
+ renderButton({ updateRelease: mockUpdateRelease });
+
+ const button = screen.getByRole('button', { name: /Upgrade/i });
+ await user.click(button);
+
+ await waitFor(() => {
+ expect(openUpgradeHelmModal).toHaveBeenCalled();
+ });
+
+ expect(mockUpdateRelease).not.toHaveBeenCalled();
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx
new file mode 100644
index 000000000..4c385c388
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx
@@ -0,0 +1,167 @@
+import { ArrowUp } from 'lucide-react';
+import { useRouter } from '@uirouter/react';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { notifySuccess } from '@/portainer/services/notifications';
+import { semverCompare } from '@/react/common/semver-utils';
+
+import { LoadingButton } from '@@/buttons';
+import { InlineLoader } from '@@/InlineLoader';
+import { Tooltip } from '@@/Tip/Tooltip';
+import { Link } from '@@/Link';
+
+import { HelmRelease } from '../../types';
+import {
+ useUpdateHelmReleaseMutation,
+ UpdateHelmReleasePayload,
+} from '../queries/useUpdateHelmReleaseMutation';
+import {
+ ChartVersion,
+ useHelmRepoVersions,
+ useHelmRepositories,
+} from '../queries/useHelmRepositories';
+import { useHelmRelease } from '../queries/useHelmRelease';
+
+import { openUpgradeHelmModal } from './UpgradeHelmModal';
+
+export function UpgradeButton({
+ environmentId,
+ releaseName,
+ namespace,
+ release,
+ updateRelease,
+}: {
+ environmentId: EnvironmentId;
+ releaseName: string;
+ namespace: string;
+ release?: HelmRelease;
+ updateRelease: (release: HelmRelease) => void;
+}) {
+ const router = useRouter();
+ const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
+
+ const repositoriesQuery = useHelmRepositories();
+ const helmRepoVersionsQuery = useHelmRepoVersions(
+ release?.chart.metadata?.name || '',
+ 60 * 60 * 1000, // 1 hour
+ repositoriesQuery.data
+ );
+ const versions = helmRepoVersionsQuery.data;
+
+ // Combined loading state
+ const isInitialLoading =
+ repositoriesQuery.isInitialLoading ||
+ helmRepoVersionsQuery.isInitialLoading;
+ const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
+
+ const latestVersion = useHelmRelease(environmentId, releaseName, namespace, {
+ select: (data) => data.chart.metadata?.version,
+ });
+ const latestVersionAvailable = versions[0]?.Version ?? '';
+ const isNewVersionAvailable =
+ latestVersion?.data &&
+ semverCompare(latestVersionAvailable, latestVersion?.data) === 1;
+
+ const editableHelmRelease: UpdateHelmReleasePayload = {
+ name: releaseName,
+ namespace: namespace || '',
+ values: release?.values?.userSuppliedValues,
+ chart: release?.chart.metadata?.name || '',
+ version: release?.chart.metadata?.version,
+ };
+
+ return (
+
+ openUpgradeForm(versions, release)}
+ disabled={
+ versions.length === 0 ||
+ isInitialLoading ||
+ isError ||
+ release?.info?.status?.startsWith('pending')
+ }
+ loadingText="Upgrading..."
+ isLoading={updateHelmReleaseMutation.isLoading}
+ icon={ArrowUp}
+ size="medium"
+ >
+ Upgrade
+
+ {versions.length === 0 && isInitialLoading && (
+
+ Checking for new versions...
+
+ )}
+ {versions.length === 0 && !isInitialLoading && !isError && (
+
+ No versions available
+
+ Portainer is unable to find any versions for this chart in the
+ repositories saved. Try adding a new repository which contains
+ the chart in the{' '}
+
+ Helm repositories settings
+
+
+ }
+ />
+
+ )}
+ {isNewVersionAvailable && (
+
+ New version available ({latestVersionAvailable})
+
+ )}
+
+ );
+
+ async function openUpgradeForm(
+ versions: ChartVersion[],
+ release?: HelmRelease
+ ) {
+ const result = await openUpgradeHelmModal(editableHelmRelease, versions);
+
+ if (result) {
+ handleUpgrade(result, release);
+ }
+ }
+
+ function handleUpgrade(
+ payload: UpdateHelmReleasePayload,
+ release?: HelmRelease
+ ) {
+ if (release?.info) {
+ const updatedRelease = {
+ ...release,
+ info: {
+ ...release.info,
+ status: 'pending-upgrade',
+ description: 'Preparing upgrade',
+ },
+ };
+ updateRelease(updatedRelease);
+ }
+ updateHelmReleaseMutation.mutate(payload, {
+ onSuccess: () => {
+ notifySuccess('Success', 'Helm chart upgraded successfully');
+ // set the revision url param to undefined to refresh the page at the latest revision
+ router.stateService.go('kubernetes.helm', {
+ namespace,
+ name: releaseName,
+ revision: undefined,
+ });
+ },
+ });
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx
new file mode 100644
index 000000000..bb71f28c5
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeHelmModal.tsx
@@ -0,0 +1,151 @@
+import { useState } from 'react';
+import { ArrowUp } from 'lucide-react';
+
+import { withReactQuery } from '@/react-tools/withReactQuery';
+import { withCurrentUser } from '@/react-tools/withCurrentUser';
+
+import { Modal, OnSubmit, openModal } from '@@/modals';
+import { Button } from '@@/buttons';
+import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
+import { Input } from '@@/form-components/Input';
+import { CodeEditor } from '@@/CodeEditor';
+import { FormControl } from '@@/form-components/FormControl';
+import { WidgetTitle } from '@@/Widget';
+
+import { UpdateHelmReleasePayload } from '../queries/useUpdateHelmReleaseMutation';
+import { ChartVersion } from '../queries/useHelmRepositories';
+
+interface Props {
+ onSubmit: OnSubmit
;
+ values: UpdateHelmReleasePayload;
+ versions: ChartVersion[];
+}
+
+export function UpgradeHelmModal({ values, versions, onSubmit }: Props) {
+ const versionOptions: Option[] = versions.map((version) => {
+ const isCurrentVersion = version.Version === values.version;
+ const label = `${version.Repo}@${version.Version}${
+ isCurrentVersion ? ' (current)' : ''
+ }`;
+ return {
+ label,
+ value: version,
+ };
+ });
+ const defaultVersion =
+ versionOptions.find((v) => v.value.Version === values.version)?.value ||
+ versionOptions[0]?.value;
+ const [version, setVersion] = useState(defaultVersion);
+ const [userValues, setUserValues] = useState(values.values || '');
+
+ return (
+ onSubmit()}
+ size="lg"
+ className="flex flex-col h-[80vh] px-0"
+ aria-label="upgrade-helm"
+ >
+ }
+ />
+
+
+
+ onSubmit()}
+ color="secondary"
+ key="cancel-button"
+ size="medium"
+ data-cy="cancel-button-cy"
+ >
+ Cancel
+
+
+ onSubmit({
+ name: values.name,
+ values: userValues,
+ namespace: values.namespace,
+ chart: values.chart,
+ repo: version.Repo,
+ version: version.Version,
+ })
+ }
+ color="primary"
+ key="update-button"
+ size="medium"
+ data-cy="update-button-cy"
+ >
+ Upgrade
+
+
+
+
+ );
+}
+
+export async function openUpgradeHelmModal(
+ values: UpdateHelmReleasePayload,
+ versions: ChartVersion[]
+) {
+ return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
+ values,
+ versions,
+ });
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
index c0e6b8d71..89f522d3a 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import { HttpResponse } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
@@ -7,12 +7,14 @@ import { withTestRouter } from '@/react/test-utils/withRouter';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { mockCodeMirror } from '@/setup-tests/mock-codemirror';
+import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
import { HelmApplicationView } from './HelmApplicationView';
// Mock the necessary hooks and dependencies
const mockUseCurrentStateAndParams = vi.fn();
const mockUseEnvironmentId = vi.fn();
+mockLocalizeDate();
vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({
...(await importOriginal()),
@@ -32,32 +34,109 @@ const minimalHelmRelease = {
chart: {
metadata: {
name: 'test-chart',
- // appVersion: '1.0.0', // can be missing for a minimal release
version: '2.2.2',
},
},
info: {
status: 'deployed',
+ last_deployed: '2021-01-01T00:00:00Z',
// notes: 'This is a test note', // can be missing for a minimal release
},
manifest: 'This is a test manifest',
};
-const helmReleaseWithAdditionalDetails = {
- ...minimalHelmRelease,
- info: {
- ...minimalHelmRelease.info,
- notes: 'This is a test note',
- },
+// Create a more complete helm release object for testing
+const completeHelmRelease = {
+ name: 'test-release',
+ version: '1',
+ namespace: 'default',
chart: {
- ...minimalHelmRelease.chart,
metadata: {
- ...minimalHelmRelease.chart.metadata,
+ name: 'test-chart',
appVersion: '1.0.0',
+ version: '2.2.2',
},
},
+ info: {
+ status: 'deployed',
+ notes: 'This is a test note',
+ resources: [
+ {
+ kind: 'Deployment',
+ name: 'test-deployment',
+ namespace: 'default',
+ uid: 'test-deployment-uid',
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Running',
+ message: 'All replicas are ready',
+ },
+ },
+ metadata: {
+ name: 'test-deployment',
+ namespace: 'default',
+ },
+ },
+ {
+ kind: 'Service',
+ name: 'test-service',
+ namespace: 'default',
+ uid: 'test-service-uid',
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Available',
+ message: 'Service is available',
+ },
+ },
+ metadata: {
+ name: 'test-service',
+ namespace: 'default',
+ },
+ },
+ ],
+ },
+ manifest: 'This is a test manifest',
+ values: {
+ // Add some values to ensure the Values tab is present
+ replicaCount: 1,
+ image: {
+ repository: 'nginx',
+ tag: 'latest',
+ },
+ },
+ resources: [
+ {
+ kind: 'Deployment',
+ name: 'test-deployment',
+ namespace: 'default',
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Running',
+ message: 'All replicas are ready',
+ },
+ },
+ metadata: {
+ name: 'test-deployment',
+ namespace: 'default',
+ },
+ },
+ ],
};
+const helmReleaseHistory = [
+ {
+ version: 1,
+ updated: '2023-06-01T12:00:00Z',
+ status: 'deployed',
+ chart: 'test-chart-1.0.0',
+ app_version: '1.0.0',
+ description: 'Install complete',
+ },
+];
+
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
@@ -66,92 +145,171 @@ function renderComponent() {
return render( );
}
-describe('HelmApplicationView', () => {
- beforeEach(() => {
- // Set up default mock values
- mockUseEnvironmentId.mockReturnValue(3);
- mockUseCurrentStateAndParams.mockReturnValue({
- params: {
- name: 'test-release',
- namespace: 'default',
- },
+describe(
+ 'HelmApplicationView',
+ () => {
+ beforeEach(() => {
+ // Set up default mock values
+ mockUseEnvironmentId.mockReturnValue(3);
+ mockUseCurrentStateAndParams.mockReturnValue({
+ params: {
+ name: 'test-release',
+ namespace: 'default',
+ },
+ });
});
- });
- it('should display helm release details for minimal release when data is loaded', async () => {
- vi.spyOn(console, 'error').mockImplementation(() => {});
+ it('should display helm release details for minimal release when data is loaded', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
- server.use(
- http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
- HttpResponse.json(minimalHelmRelease)
- )
- );
+ server.use(
+ http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
+ HttpResponse.json(minimalHelmRelease)
+ ),
+ http.get('/api/users/undefined/helm/repositories', () =>
+ HttpResponse.json({
+ GlobalRepository: 'https://charts.helm.sh/stable',
+ UserRepositories: [
+ { Id: '1', URL: 'https://charts.helm.sh/stable' },
+ ],
+ })
+ ),
+ http.get('/api/templates/helm', () =>
+ HttpResponse.json({
+ entries: {
+ 'test-chart': [{ version: '1.0.0' }],
+ },
+ })
+ ),
+ http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
+ HttpResponse.json(helmReleaseHistory)
+ ),
+ http.get(
+ '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
+ () =>
+ HttpResponse.json({
+ kind: 'EventList',
+ apiVersion: 'v1',
+ metadata: { resourceVersion: '12345' },
+ items: [],
+ })
+ )
+ );
- const { findByText, findAllByText } = renderComponent();
+ const { findByText, findAllByText } = renderComponent();
- // Check for the page header
- expect(await findByText('Helm details')).toBeInTheDocument();
+ // Check for the page header
+ expect(await findByText('Helm details')).toBeInTheDocument();
- // Check for the badge content
- expect(await findByText(/Namespace/)).toBeInTheDocument();
- expect(await findByText(/Chart version:/)).toBeInTheDocument();
- expect(await findByText(/Chart:/)).toBeInTheDocument();
- expect(await findByText(/Revision/)).toBeInTheDocument();
+ // Check for the badge content
+ expect(await findByText(/Namespace: default/)).toBeInTheDocument();
+ expect(
+ await findByText(/Chart version: test-chart-2.2.2/)
+ ).toBeInTheDocument();
+ expect(await findByText(/Chart: test-chart/)).toBeInTheDocument();
+ expect(await findByText(/Revision: #1/)).toBeInTheDocument();
+ expect(
+ await findByText(/Last deployed: Jan 1, 2021, 12:00 AM/)
+ ).toBeInTheDocument();
+ // Check for the actual values
+ expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
+ expect(await findAllByText(/test-chart/)).toHaveLength(2); // title and badge (not checking revision list item)
- // Check for the actual values
- expect(await findAllByText(/test-release/)).toHaveLength(2); // title and badge
- expect(await findAllByText(/test-chart/)).toHaveLength(2);
+ // There shouldn't be a notes tab when there are no notes
+ expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
- // There shouldn't be a notes tab when there are no notes
- expect(screen.queryByText(/Notes/)).not.toBeInTheDocument();
+ // There shouldn't be an app version badge when it's missing
+ expect(screen.queryByText(/App version/)).not.toBeInTheDocument();
- // There shouldn't be an app version badge when it's missing
- expect(screen.queryByText(/App version/)).not.toBeInTheDocument();
+ // Ensure there are no console errors
+ // eslint-disable-next-line no-console
+ expect(console.error).not.toHaveBeenCalled();
- // Ensure there are no console errors
- // eslint-disable-next-line no-console
- expect(console.error).not.toHaveBeenCalled();
+ // Restore console.error
+ vi.spyOn(console, 'error').mockRestore();
+ });
- // Restore console.error
- vi.spyOn(console, 'error').mockRestore();
- });
+ it('should display error message when API request fails', async () => {
+ // Mock API failure
+ server.use(
+ http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
+ HttpResponse.error()
+ ),
+ // Add mock for events endpoint
+ http.get(
+ '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
+ () =>
+ HttpResponse.json({
+ kind: 'EventList',
+ apiVersion: 'v1',
+ metadata: { resourceVersion: '12345' },
+ items: [],
+ })
+ )
+ );
- it('should display error message when API request fails', async () => {
- // Mock API failure
- server.use(
- http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
- HttpResponse.error()
- )
- );
+ // Mock console.error to prevent test output pollution
+ vi.spyOn(console, 'error').mockImplementation(() => {});
- // Mock console.error to prevent test output pollution
- vi.spyOn(console, 'error').mockImplementation(() => {});
+ renderComponent();
- renderComponent();
+ // Wait for the error message to appear
+ expect(
+ await screen.findByText(
+ 'Failed to load Helm application details',
+ {},
+ { timeout: 6500 }
+ )
+ ).toBeInTheDocument();
- // Wait for the error message to appear
- expect(
- await screen.findByText('Failed to load Helm application details')
- ).toBeInTheDocument();
+ // Restore console.error
+ vi.spyOn(console, 'error').mockRestore();
+ });
- // Restore console.error
- vi.spyOn(console, 'error').mockRestore();
- });
+ it('should display additional details when available in helm release', async () => {
+ server.use(
+ http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
+ HttpResponse.json(completeHelmRelease)
+ ),
+ http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
+ HttpResponse.json(helmReleaseHistory)
+ ),
+ http.get(
+ '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
+ () =>
+ HttpResponse.json({
+ kind: 'EventList',
+ apiVersion: 'v1',
+ metadata: { resourceVersion: '12345' },
+ items: [],
+ })
+ )
+ );
- it('should display additional details when available in helm release', async () => {
- server.use(
- http.get('/api/endpoints/3/kubernetes/helm/test-release', () =>
- HttpResponse.json(helmReleaseWithAdditionalDetails)
- )
- );
+ const { findByText } = renderComponent();
- const { findByText } = renderComponent();
+ expect(await findByText('Helm details')).toBeInTheDocument();
- // Check for the notes tab when notes are available
- expect(await findByText(/Notes/)).toBeInTheDocument();
+ // Check for the app version badge when it's available
+ await waitFor(() => {
+ expect(
+ screen.getByText(/App version/, { exact: false })
+ ).toBeInTheDocument();
+ });
- // Check for the app version badge when it's available
- expect(await findByText(/App version/)).toBeInTheDocument();
- expect(await findByText('1.0.0', { exact: false })).toBeInTheDocument();
- });
-});
+ await waitFor(() => {
+ // Look for specific tab text
+ expect(screen.getByText('Resources')).toBeInTheDocument();
+ expect(screen.getByText('Values')).toBeInTheDocument();
+ expect(screen.getByText('Manifest')).toBeInTheDocument();
+ expect(screen.getByText('Notes')).toBeInTheDocument();
+ expect(screen.getByText('Events')).toBeInTheDocument();
+ });
+
+ expect(await findByText(/App version: 1.0.0/)).toBeInTheDocument();
+ });
+ },
+ {
+ timeout: 7000,
+ }
+);
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
index 164943223..625bb55fe 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.tsx
@@ -1,4 +1,5 @@
import { useCurrentStateAndParams } from '@uirouter/react';
+import { useQueryClient } from '@tanstack/react-query';
import helm from '@/assets/ico/vendor/helm.svg?c';
import { PageHeader } from '@/react/components/PageHeader';
@@ -15,14 +16,25 @@ import { HelmSummary } from './HelmSummary';
import { ReleaseTabs } from './ReleaseDetails/ReleaseTabs';
import { useHelmRelease } from './queries/useHelmRelease';
import { ChartActions } from './ChartActions/ChartActions';
+import { HelmRevisionList } from './HelmRevisionList';
+import { HelmRevisionListSheet } from './HelmRevisionListSheet';
+import { useHelmHistory } from './queries/useHelmHistory';
export function HelmApplicationView() {
const environmentId = useEnvironmentId();
+ const queryClient = useQueryClient();
const { params } = useCurrentStateAndParams();
- const { name, namespace } = params;
+ const { name, namespace, revision } = params;
+ const helmHistoryQuery = useHelmHistory(environmentId, name, namespace);
+ const latestRevision = helmHistoryQuery.data?.[0]?.version;
+ const earlistRevision =
+ helmHistoryQuery.data?.[helmHistoryQuery.data.length - 1]?.version;
+ // when loading the page fresh, the revision is undefined, so use the latest revision
+ const selectedRevision = revision ? parseInt(revision, 10) : latestRevision;
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
showResources: true,
+ revision: selectedRevision,
});
return (
@@ -38,26 +50,61 @@ export function HelmApplicationView() {
-
- {name && (
-
-
-
+
+
+ {name && (
+
+
+
+
+
+
+ {
+ queryClient.setQueryData(
+ [
+ environmentId,
+ 'helm',
+ 'releases',
+ namespace,
+ name,
+ true,
+ ],
+ updatedRelease
+ );
+ }}
+ />
+
+
+
+ )}
+
+
-
-
- )}
-
-
-
+
+
+
+
+
+
@@ -68,10 +115,16 @@ export function HelmApplicationView() {
type HelmDetailsProps = {
isLoading: boolean;
isError: boolean;
- release: HelmRelease | undefined;
+ selectedRevision?: number;
+ release?: HelmRelease;
};
-function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) {
+function HelmDetails({
+ isLoading,
+ isError,
+ release,
+ selectedRevision,
+}: HelmDetailsProps) {
if (isLoading) {
return ;
}
@@ -82,16 +135,16 @@ function HelmDetails({ isLoading, isError, release: data }: HelmDetailsProps) {
);
}
- if (!data) {
+ if (!release || !selectedRevision) {
return ;
}
return (
<>
-
+
-
+
>
);
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.test.tsx
new file mode 100644
index 000000000..638197ae0
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.test.tsx
@@ -0,0 +1,116 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { withTestRouter } from '@/react/test-utils/withRouter';
+import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
+
+import { HelmRelease } from '../types';
+
+import { HelmRevisionItem } from './HelmRevisionItem';
+
+const mockHelmRelease: HelmRelease = {
+ name: 'my-release',
+ version: 1,
+ info: {
+ status: 'deployed',
+ last_deployed: '2024-01-01T00:00:00Z',
+ },
+ chart: {
+ metadata: {
+ name: 'my-app',
+ version: '1.0.0',
+ },
+ },
+ manifest: 'apiVersion: v1\nkind: Service\nmetadata:\n name: my-service',
+};
+
+mockLocalizeDate();
+
+vi.mock('@uirouter/react', () => ({
+ useCurrentStateAndParams: () => ({
+ params: {
+ namespace: 'default',
+ name: 'my-release',
+ },
+ }),
+}));
+
+const mockUseCurrentStateAndParams = vi.fn();
+
+vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({
+ ...(await importOriginal()),
+ useCurrentStateAndParams: () => mockUseCurrentStateAndParams(),
+}));
+
+function getTestComponent() {
+ return withTestRouter(HelmRevisionItem);
+}
+
+describe('HelmRevisionItem', () => {
+ it('should display correct revision details', () => {
+ const TestComponent = getTestComponent();
+ render(
+
+ );
+
+ // Check status badge
+ expect(screen.getByText('Deployed')).toBeInTheDocument();
+
+ // Check revision number
+ expect(screen.getByText('Revision #1')).toBeInTheDocument();
+
+ // Check chart name and version
+ expect(screen.getByText('my-app-1.0.0')).toBeInTheDocument();
+
+ // Check deployment date
+ expect(screen.getByText('Jan 1, 2024, 12:00 AM')).toBeInTheDocument();
+ });
+
+ it('should have selected class when currentRevision matches item version', () => {
+ const TestComponent = getTestComponent();
+ const { container } = render(
+
+ );
+
+ const blocklistItem = container.querySelector('.blocklist-item');
+ expect(blocklistItem).toHaveClass('blocklist-item--selected');
+ });
+
+ it('should not have selected class when currentRevision does not match item version', () => {
+ const TestComponent = getTestComponent();
+ const { container } = render(
+
+ );
+
+ const blocklistItem = container.querySelector('.blocklist-item');
+ expect(blocklistItem).not.toHaveClass('blocklist-item--selected');
+ });
+
+ it('should not have selected class when currentRevision is undefined', () => {
+ const TestComponent = getTestComponent();
+ const { container } = render(
+
+ );
+
+ const blocklistItem = container.querySelector('.blocklist-item');
+ expect(blocklistItem).not.toHaveClass('blocklist-item--selected');
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.tsx
new file mode 100644
index 000000000..73a29107f
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionItem.tsx
@@ -0,0 +1,49 @@
+import { localizeDate } from '@/react/common/date-utils';
+
+import { BlocklistItem } from '@@/Blocklist/BlocklistItem';
+import { Link } from '@@/Link';
+import { Badge } from '@@/Badge';
+
+import { HelmRelease } from '../types';
+import { getStatusColor, getStatusText } from '../helm-status-utils';
+
+export function HelmRevisionItem({
+ item,
+ currentRevision,
+ namespace,
+ name,
+}: {
+ item: HelmRelease;
+ currentRevision?: number;
+ namespace: string;
+ name: string;
+}) {
+ return (
+
+
+
+
+ {getStatusText(item.info?.status)}
+
+ Revision #{item.version}
+
+
+
+ {item.chart.metadata?.name}-{item.chart.metadata?.version}
+
+ {item.info?.last_deployed && (
+
+ {localizeDate(new Date(item.info.last_deployed))}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionList.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionList.tsx
new file mode 100644
index 000000000..07ed2a8b2
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionList.tsx
@@ -0,0 +1,43 @@
+import { useCurrentStateAndParams } from '@uirouter/react';
+import { History } from 'lucide-react';
+
+import { WidgetIcon } from '@@/Widget/WidgetIcon';
+
+import { HelmRelease } from '../types';
+
+import { HelmRevisionItem } from './HelmRevisionItem';
+
+export function HelmRevisionList({
+ currentRevision,
+ history,
+}: {
+ currentRevision?: number;
+ history: HelmRelease[] | undefined;
+}) {
+ const { params } = useCurrentStateAndParams();
+ const { name, namespace } = params;
+
+ if (!history) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ Revisions
+
+ {history?.map((historyItem) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionListSheet.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionListSheet.tsx
new file mode 100644
index 000000000..95016f080
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmRevisionListSheet.tsx
@@ -0,0 +1,40 @@
+import { Eye } from 'lucide-react';
+
+import { Icon } from '@@/Icon';
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTrigger,
+} from '@@/Sheet';
+
+import { HelmRelease } from '../types';
+
+import { HelmRevisionList } from './HelmRevisionList';
+
+export function HelmRevisionListSheet({
+ currentRevision,
+ history,
+}: {
+ currentRevision: number | undefined;
+ history: HelmRelease[] | undefined;
+}) {
+ return (
+
+
+
+ View revisions
+
+
+
+
+
+ View the history of this Helm application.
+
+
+
+
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
index 29f9e07d8..a7e32b247 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmSummary.tsx
@@ -1,24 +1,19 @@
import { Badge } from '@/react/components/Badge';
+import { localizeDate } from '@/react/common/date-utils';
import { Alert } from '@@/Alert';
import { HelmRelease } from '../types';
+import {
+ DeploymentStatus,
+ getStatusColor,
+ getStatusText,
+} from '../helm-status-utils';
interface Props {
release: HelmRelease;
}
-export enum DeploymentStatus {
- DEPLOYED = 'deployed',
- FAILED = 'failed',
- PENDING = 'pending-install',
- PENDINGUPGRADE = 'pending-upgrade',
- PENDINGROLLBACK = 'pending-rollback',
- SUPERSEDED = 'superseded',
- UNINSTALLED = 'uninstalled',
- UNINSTALLING = 'uninstalling',
-}
-
export function HelmSummary({ release }: Props) {
const isSuccess =
release.info?.status === DeploymentStatus.DEPLOYED ||
@@ -29,9 +24,14 @@ export function HelmSummary({ release }: Props) {
- {getText(release.info?.status)}
+ {getStatusText(release.info?.status)}
+ {!!release.info?.description && !isSuccess && (
+
+ {release.info?.description}
+
+ )}
{!!release.namespace && Namespace: {release.namespace} }
{!!release.version && Revision: #{release.version} }
@@ -47,12 +47,13 @@ export function HelmSummary({ release }: Props) {
{release.chart.metadata.version}
)}
+ {!!release.info?.last_deployed && (
+
+ Last deployed:{' '}
+ {localizeDate(new Date(release.info.last_deployed))}
+
+ )}
- {!!release.info?.description && !isSuccess && (
-
- {release.info?.description}
-
- )}
);
@@ -74,38 +75,3 @@ function getAlertColor(status?: string) {
return 'info';
}
}
-
-function getStatusColor(status?: string) {
- switch (status?.toLowerCase()) {
- case DeploymentStatus.DEPLOYED:
- return 'success';
- case DeploymentStatus.FAILED:
- return 'danger';
- case DeploymentStatus.PENDING:
- case DeploymentStatus.PENDINGUPGRADE:
- case DeploymentStatus.PENDINGROLLBACK:
- case DeploymentStatus.UNINSTALLING:
- return 'warn';
- case DeploymentStatus.SUPERSEDED:
- default:
- return 'info';
- }
-}
-
-function getText(status?: string) {
- switch (status?.toLowerCase()) {
- case DeploymentStatus.DEPLOYED:
- return 'Deployed';
- case DeploymentStatus.FAILED:
- return 'Failed';
- case DeploymentStatus.PENDING:
- case DeploymentStatus.PENDINGUPGRADE:
- case DeploymentStatus.PENDINGROLLBACK:
- case DeploymentStatus.UNINSTALLING:
- return 'Pending';
- case DeploymentStatus.SUPERSEDED:
- return 'Superseded';
- default:
- return 'Unknown';
- }
-}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.test.tsx
new file mode 100644
index 000000000..109d98299
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.test.tsx
@@ -0,0 +1,193 @@
+import { fireEvent, render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { vi } from 'vitest';
+
+import { DiffControl, DiffViewMode } from './DiffControl';
+
+// Create a mock for useDebounce that directly passes the setter function
+vi.mock('@/react/hooks/useDebounce', () => ({
+ useDebounce: (initialValue: number, setter: (value: number) => void) =>
+ // Return the initial value and a function that directly calls the setter
+ [initialValue, setter],
+}));
+
+function renderComponent({
+ selectedRevisionNumber = 5,
+ latestRevisionNumber = 10,
+ compareRevisionNumber = 4,
+ setCompareRevisionNumber = vi.fn(),
+ earliestRevisionNumber = 1,
+ diffViewMode = 'view' as DiffViewMode,
+ setDiffViewMode = vi.fn(),
+ isUserSupplied = false,
+ setIsUserSupplied = vi.fn(),
+ showUserSuppliedCheckbox = false,
+} = {}) {
+ return render(
+
+ );
+}
+
+describe('DiffControl', () => {
+ it('should only render the user supplied checkbox when latestRevisionNumber is 1 and showUserSuppliedCheckbox is true', () => {
+ const { queryByLabelText } = renderComponent({
+ latestRevisionNumber: 1,
+ showUserSuppliedCheckbox: true,
+ setIsUserSupplied: vi.fn(),
+ });
+ expect(queryByLabelText('View')).toBeNull();
+ expect(queryByLabelText('Diff with previous')).toBeNull();
+ expect(queryByLabelText('Diff with specific revision:')).toBeNull();
+ expect(queryByLabelText('User defined only')).toBeInTheDocument();
+ });
+
+ it('should not render any controls when latestRevisionNumber is 1 and showUserSuppliedCheckbox is false', () => {
+ const { queryByLabelText } = renderComponent({
+ latestRevisionNumber: 1,
+ showUserSuppliedCheckbox: false,
+ });
+ expect(queryByLabelText('Diff with previous')).toBeNull();
+ expect(queryByLabelText('Diff with specific revision:')).toBeNull();
+ expect(queryByLabelText('View')).toBeNull();
+ expect(queryByLabelText('User defined only')).toBeNull();
+ });
+
+ it('should render view option', () => {
+ const { getByLabelText } = renderComponent();
+ expect(getByLabelText('View')).toBeInTheDocument();
+ });
+
+ it('should render "Diff with previous" option when earliestRevisionNumber < selectedRevisionNumber', () => {
+ const { getByLabelText } = renderComponent({
+ earliestRevisionNumber: 3,
+ selectedRevisionNumber: 5,
+ });
+ expect(getByLabelText('Diff with previous')).toBeInTheDocument();
+ });
+
+ it('should render "Diff with previous" option as disabled when earliestRevisionNumber >= selectedRevisionNumber', () => {
+ const { getByLabelText } = renderComponent({
+ earliestRevisionNumber: 5,
+ selectedRevisionNumber: 5,
+ });
+
+ expect(getByLabelText('View')).toBeInTheDocument();
+ expect(getByLabelText('Diff with specific revision:')).toBeInTheDocument();
+ // 'Diff with previous' should exist and be disabled
+ const diffWithPreviousOption = getByLabelText('Diff with previous');
+ expect(diffWithPreviousOption).toBeInTheDocument();
+ expect(diffWithPreviousOption).toBeDisabled();
+ });
+
+ it('should render "Diff with specific revision" option', () => {
+ const { getByLabelText } = renderComponent();
+ expect(getByLabelText('Diff with specific revision:')).toBeInTheDocument();
+ });
+
+ it('should render user supplied checkbox when showUserSuppliedCheckbox is true', () => {
+ const { getByLabelText } = renderComponent({
+ showUserSuppliedCheckbox: true,
+ });
+ expect(getByLabelText('User defined only')).toBeInTheDocument();
+ });
+
+ it('should not render user supplied checkbox when showUserSuppliedCheckbox is false', () => {
+ const { queryByLabelText } = renderComponent({
+ showUserSuppliedCheckbox: false,
+ });
+ expect(queryByLabelText('User defined only')).not.toBeInTheDocument();
+ });
+
+ it('should call setDiffViewMode when a radio option is selected', async () => {
+ const user = userEvent.setup();
+ const setDiffViewMode = vi.fn();
+
+ const { getByLabelText } = renderComponent({
+ setDiffViewMode,
+ diffViewMode: 'view',
+ });
+
+ await user.click(getByLabelText('Diff with specific revision:'));
+ expect(setDiffViewMode).toHaveBeenCalledWith('specific');
+ });
+
+ it('should call setIsUserSupplied when checkbox is clicked', async () => {
+ const user = userEvent.setup();
+ const setIsUserSupplied = vi.fn();
+
+ const { getByLabelText } = renderComponent({
+ setIsUserSupplied,
+ isUserSupplied: false,
+ showUserSuppliedCheckbox: true,
+ });
+
+ await user.click(getByLabelText('User defined only'));
+ expect(setIsUserSupplied).toHaveBeenCalledWith(true);
+ });
+});
+
+describe('DiffWithSpecificRevision', () => {
+ it('should display input with compareRevisionNumber value when not NaN', () => {
+ const compareRevisionNumber = 3;
+ const { getByRole } = renderComponent({
+ diffViewMode: 'specific',
+ compareRevisionNumber,
+ });
+
+ const input = getByRole('spinbutton');
+ expect(input).toHaveValue(compareRevisionNumber);
+ });
+
+ it('should handle input values and constraints properly', () => {
+ const setCompareRevisionNumber = vi.fn();
+ const earliestRevisionNumber = 2;
+ const latestRevisionNumber = 10;
+
+ const { getByRole } = renderComponent({
+ diffViewMode: 'specific',
+ earliestRevisionNumber,
+ latestRevisionNumber,
+ setCompareRevisionNumber,
+ compareRevisionNumber: 4,
+ });
+
+ // Check that input has the right min/max attributes
+ const input = getByRole('spinbutton');
+ expect(input).toHaveAttribute('min', earliestRevisionNumber.toString());
+ expect(input).toHaveAttribute('max', latestRevisionNumber.toString());
+
+ fireEvent.change(input, { target: { valueAsNumber: 11 } });
+ expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(
+ latestRevisionNumber
+ );
+
+ fireEvent.change(input, { target: { valueAsNumber: 1 } });
+ expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(
+ earliestRevisionNumber
+ );
+
+ fireEvent.change(input, { target: { valueAsNumber: 5 } });
+ expect(setCompareRevisionNumber).toHaveBeenLastCalledWith(5);
+ });
+
+ it('should handle NaN values in the input as empty string', () => {
+ const { getByRole } = renderComponent({
+ diffViewMode: 'specific',
+ compareRevisionNumber: NaN,
+ });
+
+ const input = getByRole('spinbutton') as HTMLInputElement;
+ expect(input.value).toBe('');
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.tsx
new file mode 100644
index 000000000..62698c40f
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffControl.tsx
@@ -0,0 +1,146 @@
+import { ChangeEvent } from 'react';
+
+import { useDebounce } from '@/react/hooks/useDebounce';
+
+import { RadioGroup, RadioGroupOption } from '@@/RadioGroup/RadioGroup';
+import { Input } from '@@/form-components/Input';
+import { Checkbox } from '@@/form-components/Checkbox';
+
+import {
+ LatestRevisionNumber,
+ EarliestRevisionNumber,
+ CompareRevisionNumber,
+ SelectedRevisionNumber,
+} from './types';
+
+export type DiffViewMode = 'view' | 'previous' | 'specific';
+
+type Props = {
+ selectedRevisionNumber: SelectedRevisionNumber;
+ latestRevisionNumber: LatestRevisionNumber;
+ compareRevisionNumber: CompareRevisionNumber;
+ setCompareRevisionNumber: (
+ compareRevisionNumber: CompareRevisionNumber
+ ) => void;
+ earliestRevisionNumber: EarliestRevisionNumber;
+ diffViewMode: DiffViewMode;
+ setDiffViewMode: (diffViewMode: DiffViewMode) => void;
+ isUserSupplied?: boolean;
+ setIsUserSupplied?: (isUserSupplied: boolean) => void;
+ showUserSuppliedCheckbox?: boolean;
+};
+
+export function DiffControl({
+ selectedRevisionNumber,
+ latestRevisionNumber,
+ compareRevisionNumber,
+ setCompareRevisionNumber,
+ earliestRevisionNumber,
+ diffViewMode,
+ setDiffViewMode,
+ isUserSupplied,
+ setIsUserSupplied,
+ showUserSuppliedCheckbox,
+}: Props) {
+ // If there is a different version to compare, show view option radio group
+ const showViewOptions = latestRevisionNumber > earliestRevisionNumber;
+
+ // to show the previous option, the earliest revision number available must be less than the selected revision number. (compare is still allowed, because we can still compare with a later revision)
+ const disabledPreviousOption =
+ earliestRevisionNumber >= selectedRevisionNumber;
+
+ const options: Array> = [
+ { label: 'View', value: 'view' },
+ {
+ label: 'Diff with previous',
+ value: 'previous',
+ disabled: disabledPreviousOption,
+ },
+ {
+ label: (
+
+ ),
+ value: 'specific',
+ },
+ ];
+
+ return (
+
+ {showViewOptions && (
+
+ )}
+ {!!showUserSuppliedCheckbox && !!setIsUserSupplied && (
+ setIsUserSupplied(!isUserSupplied)}
+ data-cy="values-details-user-supplied"
+ className="font-normal control-label"
+ bold={false}
+ />
+ )}
+
+ );
+}
+
+function DiffWithSpecificRevision({
+ latestRevisionNumber,
+ earliestRevisionNumber,
+ compareRevisionNumber,
+ setCompareRevisionNumber,
+}: {
+ latestRevisionNumber: LatestRevisionNumber;
+ earliestRevisionNumber: EarliestRevisionNumber;
+ compareRevisionNumber: CompareRevisionNumber;
+ setCompareRevisionNumber: (
+ compareRevisionNumber: CompareRevisionNumber
+ ) => void;
+}) {
+ // the revision number is debounced to avoid too many requests to the backend
+ const [
+ debouncedSetCompareRevisionNumber,
+ setDebouncedSetCompareRevisionNumber,
+ ] = useDebounce(compareRevisionNumber, setCompareRevisionNumber, 500);
+
+ return (
+ <>
+ Diff with specific revision:
+
+ >
+ );
+
+ function handleSpecificRevisionChange(e: ChangeEvent) {
+ const inputNumber = e.target.valueAsNumber;
+ // handle out of range values
+ if (inputNumber > latestRevisionNumber) {
+ setCompareRevisionNumber(latestRevisionNumber);
+ return;
+ }
+ if (inputNumber < earliestRevisionNumber) {
+ setCompareRevisionNumber(earliestRevisionNumber);
+ return;
+ }
+ setDebouncedSetCompareRevisionNumber(inputNumber);
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffViewSection.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffViewSection.tsx
new file mode 100644
index 000000000..7871cbfa3
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/DiffViewSection.tsx
@@ -0,0 +1,55 @@
+import { AutomationTestingProps } from '@/types';
+
+import { DiffViewer } from '@@/CodeEditor/DiffViewer';
+import { Loading } from '@@/Widget';
+import { Alert } from '@@/Alert';
+
+import { CompareRevisionNumberFetched, SelectedRevisionNumber } from './types';
+
+interface Props extends AutomationTestingProps {
+ isCompareReleaseLoading: boolean;
+ isCompareReleaseError: boolean;
+ compareRevisionNumberFetched?: CompareRevisionNumberFetched;
+ selectedRevisionNumber: SelectedRevisionNumber;
+ newText: string;
+ originalText: string;
+ id: string;
+}
+
+export function DiffViewSection({
+ isCompareReleaseLoading,
+ isCompareReleaseError,
+ compareRevisionNumberFetched,
+ selectedRevisionNumber,
+ newText,
+ originalText,
+ id,
+ 'data-cy': dataCy,
+}: Props) {
+ if (isCompareReleaseLoading) {
+ return ;
+ }
+
+ if (isCompareReleaseError) {
+ return Error loading compare values ;
+ }
+
+ return (
+
+ );
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx
new file mode 100644
index 000000000..772774fe3
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx
@@ -0,0 +1,242 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { HttpResponse } from 'msw';
+import { Event, EventList } from 'kubernetes-types/core/v1';
+
+import { server, http } from '@/setup-tests/server';
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+import { withTestRouter } from '@/react/test-utils/withRouter';
+import { UserViewModel } from '@/portainer/models/user';
+import { withUserProvider } from '@/react/test-utils/withUserProvider';
+import { mockLocalizeDate } from '@/setup-tests/mock-localizeDate';
+
+import { GenericResource } from '../../types';
+
+import {
+ HelmEventsDatatable,
+ filterRelatedEvents,
+} from './HelmEventsDatatable';
+
+const mockUseEnvironmentId = vi.fn();
+mockLocalizeDate();
+
+vi.mock('@/react/hooks/useEnvironmentId', () => ({
+ useEnvironmentId: () => mockUseEnvironmentId(),
+}));
+
+const testResources: GenericResource[] = [
+ {
+ kind: 'Deployment',
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Running',
+ message: 'All replicas are ready',
+ },
+ },
+ metadata: {
+ name: 'test-deployment',
+ namespace: 'default',
+ uid: 'test-deployment-uid',
+ },
+ },
+ {
+ kind: 'Service',
+ status: {
+ healthSummary: {
+ status: 'Healthy',
+ reason: 'Available',
+ message: 'Service is available',
+ },
+ },
+ metadata: {
+ name: 'test-service',
+ namespace: 'default',
+ uid: 'test-service-uid',
+ },
+ },
+];
+
+const mockEventsResponse: EventList = {
+ kind: 'EventList',
+ apiVersion: 'v1',
+ metadata: {
+ resourceVersion: '12345',
+ },
+ items: [
+ {
+ metadata: {
+ name: 'test-deployment-123456',
+ namespace: 'default',
+ uid: 'event-uid-1',
+ resourceVersion: '1000',
+ creationTimestamp: '2023-01-01T00:00:00Z',
+ },
+ involvedObject: {
+ kind: 'Deployment',
+ namespace: 'default',
+ name: 'test-deployment',
+ uid: 'test-deployment-uid',
+ apiVersion: 'apps/v1',
+ resourceVersion: '2000',
+ },
+ reason: 'ScalingReplicaSet',
+ message: 'Scaled up replica set test-deployment-abc123 to 1',
+ source: {
+ component: 'deployment-controller',
+ },
+ firstTimestamp: '2023-01-01T00:00:00Z',
+ lastTimestamp: '2023-01-01T00:00:00Z',
+ count: 1,
+ type: 'Normal',
+ reportingComponent: 'deployment-controller',
+ reportingInstance: '',
+ },
+ {
+ metadata: {
+ name: 'test-service-123456',
+ namespace: 'default',
+ uid: 'event-uid-2',
+ resourceVersion: '1001',
+ creationTimestamp: '2023-01-01T00:00:00Z',
+ },
+ involvedObject: {
+ kind: 'Service',
+ namespace: 'default',
+ name: 'test-service',
+ uid: 'test-service-uid',
+ apiVersion: 'v1',
+ resourceVersion: '2001',
+ },
+ reason: 'CreatedLoadBalancer',
+ message: 'Created load balancer',
+ source: {
+ component: 'service-controller',
+ },
+ firstTimestamp: '2023-01-01T00:00:00Z',
+ lastTimestamp: '2023-01-01T00:00:00Z',
+ count: 1,
+ type: 'Normal',
+ reportingComponent: 'service-controller',
+ reportingInstance: '',
+ },
+ ],
+};
+
+const mixedEventsResponse: EventList = {
+ kind: 'EventList',
+ apiVersion: 'v1',
+ metadata: {
+ resourceVersion: '12345',
+ },
+ items: [
+ {
+ metadata: {
+ name: 'test-deployment-123456',
+ namespace: 'default',
+ uid: 'event-uid-1',
+ resourceVersion: '1000',
+ creationTimestamp: '2023-01-01T00:00:00Z',
+ },
+ involvedObject: {
+ kind: 'Deployment',
+ namespace: 'default',
+ name: 'test-deployment',
+ uid: 'test-deployment-uid', // This matches a resource UID
+ apiVersion: 'apps/v1',
+ resourceVersion: '2000',
+ },
+ reason: 'ScalingReplicaSet',
+ message: 'Scaled up replica set test-deployment-abc123 to 1',
+ source: {
+ component: 'deployment-controller',
+ },
+ firstTimestamp: '2023-01-01T00:00:00Z',
+ lastTimestamp: '2023-01-01T00:00:00Z',
+ count: 1,
+ type: 'Normal',
+ reportingComponent: 'deployment-controller',
+ reportingInstance: '',
+ },
+ {
+ metadata: {
+ name: 'unrelated-pod-123456',
+ namespace: 'default',
+ uid: 'event-uid-3',
+ resourceVersion: '1002',
+ creationTimestamp: '2023-01-01T00:00:00Z',
+ },
+ involvedObject: {
+ kind: 'Pod',
+ namespace: 'default',
+ name: 'unrelated-pod',
+ uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs
+ apiVersion: 'v1',
+ resourceVersion: '2002',
+ },
+ reason: 'Scheduled',
+ message: 'Successfully assigned unrelated-pod to node',
+ source: {
+ component: 'default-scheduler',
+ },
+ firstTimestamp: '2023-01-01T00:00:00Z',
+ lastTimestamp: '2023-01-01T00:00:00Z',
+ count: 1,
+ reportingComponent: 'scheduler',
+ reportingInstance: '',
+ },
+ ],
+};
+
+function renderComponent() {
+ const user = new UserViewModel({ Username: 'user' });
+ mockUseEnvironmentId.mockReturnValue(3);
+
+ const HelmEventsDatatableWithProviders = withTestQueryProvider(
+ withUserProvider(withTestRouter(HelmEventsDatatable), user)
+ );
+
+ return render(
+
+ );
+}
+
+describe('HelmEventsDatatable', () => {
+ beforeEach(() => {
+ server.use(
+ http.get(
+ '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
+ () => HttpResponse.json(mockEventsResponse)
+ )
+ );
+ });
+
+ it('should render events datatable with correct title', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('Events reflect the latest revision only.')
+ ).toBeInTheDocument();
+ });
+
+ expect(screen.getByRole('table')).toBeInTheDocument();
+ });
+
+ it('should correctly filter related events using the filterRelatedEvents function', () => {
+ const filteredEvents = filterRelatedEvents(
+ mixedEventsResponse.items as Event[],
+ testResources
+ );
+
+ expect(filteredEvents.length).toBe(1);
+ expect(filteredEvents[0].involvedObject.uid).toBe('test-deployment-uid');
+
+ const unrelatedEvents = filteredEvents.filter(
+ (e) => e.involvedObject.uid === 'unrelated-pod-uid'
+ );
+ expect(unrelatedEvents.length).toBe(0);
+ });
+});
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx
new file mode 100644
index 000000000..d02423d82
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx
@@ -0,0 +1,77 @@
+import { compact } from 'lodash';
+import { Event } from 'kubernetes-types/core/v1';
+
+import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
+import { EventsDatatable } from '@/react/kubernetes/components/EventsDatatable';
+import { useEvents } from '@/react/kubernetes/queries/useEvents';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { useTableState } from '@@/datatables/useTableState';
+import { Widget } from '@@/Widget';
+import { TextTip } from '@@/Tip/TextTip';
+
+import { GenericResource } from '../../types';
+
+export const storageKey = 'k8sHelmEventsDatatable';
+export const settingsStore = createStore(storageKey, {
+ id: 'Date',
+ desc: true,
+});
+
+export function HelmEventsDatatable({
+ namespace,
+ releaseResources,
+}: {
+ namespace: string;
+ releaseResources: GenericResource[];
+}) {
+ const environmentId = useEnvironmentId();
+ const tableState = useTableState(settingsStore, storageKey);
+
+ const eventsQuery = useEvents(environmentId, {
+ namespace,
+ queryOptions: {
+ autoRefreshRate: tableState.autoRefreshRate * 1000,
+ select: (data) => filterRelatedEvents(data, releaseResources),
+ },
+ });
+
+ return (
+
+
+ Events reflect the latest revision only.
+
+ }
+ titleIcon={null}
+ tableState={tableState}
+ isLoading={eventsQuery.isInitialLoading}
+ data-cy="k8sAppDetail-eventsTable"
+ // no widget to avoid extra padding from app/react/components/datatables/TableContainer.tsx
+ noWidget
+ />
+
+ );
+}
+
+export function useHelmEventsTableState() {
+ return useTableState(settingsStore, storageKey);
+}
+
+export function filterRelatedEvents(
+ events: Event[],
+ resources: GenericResource[]
+) {
+ const relatedUids = getReleaseUids(resources);
+ const relatedUidsSet = new Set(relatedUids);
+ return events.filter(
+ (event) =>
+ event.involvedObject.uid && relatedUidsSet.has(event.involvedObject.uid)
+ );
+}
+
+function getReleaseUids(resources: GenericResource[]) {
+ return compact(resources.map((resource) => resource.metadata.uid));
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx
index c95886d3f..c87ad17a3 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ManifestDetails.tsx
@@ -1,18 +1,58 @@
+import { ReactNode } from 'react';
+
import { CodeEditor } from '@@/CodeEditor';
+import { DiffViewMode } from './DiffControl';
+import { DiffViewSection } from './DiffViewSection';
+import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
+
type Props = {
manifest: string;
+ selectedRevisionNumber: SelectedRevisionNumber;
+ diffViewMode: DiffViewMode;
+ compareManifest?: string;
+ compareRevisionNumberFetched?: CompareRevisionNumberFetched;
+ isCompareReleaseLoading: boolean;
+ isCompareReleaseError: boolean;
+ diffControl: ReactNode;
};
-export function ManifestDetails({ manifest }: Props) {
+export function ManifestDetails({
+ manifest,
+ selectedRevisionNumber,
+ diffViewMode,
+ compareManifest,
+ compareRevisionNumberFetched,
+ isCompareReleaseLoading,
+ isCompareReleaseError,
+ diffControl,
+}: Props) {
return (
-
+ <>
+ {diffControl}
+ {diffViewMode === 'view' ? (
+
+ ) : (
+
+ )}
+ >
);
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx
index 74a6135bd..be5feb0ca 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/NotesDetails.tsx
@@ -1,9 +1,48 @@
import Markdown from 'markdown-to-jsx';
+import { ReactNode } from 'react';
+
+import { DiffViewMode } from './DiffControl';
+import { DiffViewSection } from './DiffViewSection';
+import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
type Props = {
notes: string;
+ selectedRevisionNumber: SelectedRevisionNumber;
+ diffViewMode: DiffViewMode;
+ compareNotes?: string;
+ compareRevisionNumberFetched?: CompareRevisionNumberFetched;
+ isCompareReleaseLoading: boolean;
+ isCompareReleaseError: boolean;
+ diffControl: ReactNode;
};
-export function NotesDetails({ notes }: Props) {
- return {notes} ;
+export function NotesDetails({
+ notes,
+ selectedRevisionNumber,
+ diffViewMode,
+ compareNotes,
+ compareRevisionNumberFetched,
+ isCompareReleaseLoading,
+ isCompareReleaseError,
+ diffControl,
+}: Props) {
+ return (
+ <>
+ {diffControl}
+ {diffViewMode === 'view' ? (
+ {notes}
+ ) : (
+
+ )}
+ >
+ );
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
index efa36c0c2..0aa05960c 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ReleaseTabs.tsx
@@ -1,32 +1,184 @@
import { useState } from 'react';
import { compact } from 'lodash';
+import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
+import { AlertTriangle } from 'lucide-react';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { useEvents } from '@/react/kubernetes/queries/useEvents';
import { NavTabs, Option } from '@@/NavTabs';
+import { Badge } from '@@/Badge';
+import { Icon } from '@@/Icon';
import { HelmRelease } from '../../types';
+import { useHelmHistory } from '../queries/useHelmHistory';
import { ManifestDetails } from './ManifestDetails';
import { NotesDetails } from './NotesDetails';
import { ValuesDetails } from './ValuesDetails';
import { ResourcesTable } from './ResourcesTable/ResourcesTable';
+import { DiffControl, DiffViewMode } from './DiffControl';
+import { useHelmReleaseToCompare } from './useHelmReleaseToCompare';
+import {
+ filterRelatedEvents,
+ HelmEventsDatatable,
+ useHelmEventsTableState,
+} from './HelmEventsDatatable';
type Props = {
release: HelmRelease;
+ selectedRevision: number;
};
-type Tab = 'values' | 'notes' | 'manifest' | 'resources';
+type Tab = 'values' | 'notes' | 'manifest' | 'resources' | 'events';
+
+export function ReleaseTabs({ release, selectedRevision }: Props) {
+ const {
+ params: { tab },
+ } = useCurrentStateAndParams();
+ const router = useRouter();
+ const environmentId = useEnvironmentId();
+ // state is here so that the state isn't lost when the tab changes
+ const [isUserSupplied, setIsUserSupplied] = useState(true);
+ // start with NaN so that the input is empty (see for more details)
+ const [selectedCompareRevisionNumber, setSelectedCompareRevisionNumber] =
+ useState(NaN);
+ const [diffViewMode, setDiffViewMode] = useState('view');
+
+ const historyQuery = useHelmHistory(
+ environmentId,
+ release.name,
+ release.namespace ?? ''
+ );
+ const earliestRevisionNumber =
+ historyQuery.data?.[historyQuery.data.length - 1]?.version ??
+ release.version ??
+ 1;
+ const latestRevisionNumber =
+ historyQuery.data?.[0]?.version ?? release.version ?? 1;
+ const { compareRelease, isCompareReleaseLoading, isCompareReleaseError } =
+ useHelmReleaseToCompare(
+ release,
+ earliestRevisionNumber,
+ latestRevisionNumber,
+ diffViewMode,
+ selectedRevision,
+ selectedCompareRevisionNumber
+ );
+
+ const { autoRefreshRate } = useHelmEventsTableState();
+ const { data: eventWarningCount } = useEvents(environmentId, {
+ namespace: release.namespace ?? '',
+ queryOptions: {
+ autoRefreshRate: autoRefreshRate * 1000,
+ select: (data) => {
+ const relatedEvents = filterRelatedEvents(
+ data,
+ release.info?.resources ?? []
+ );
+ return relatedEvents.filter((e) => e.type === 'Warning').length;
+ },
+ },
+ });
+
+ return (
+
+ onSelect={setTab}
+ selectedId={parseValidTab(tab, !!release.info?.notes)}
+ type="pills"
+ justified
+ options={helmTabs(
+ release,
+ isUserSupplied,
+ setIsUserSupplied,
+ earliestRevisionNumber,
+ latestRevisionNumber,
+ selectedRevision,
+ selectedCompareRevisionNumber,
+ setSelectedCompareRevisionNumber,
+ diffViewMode,
+ handleDiffViewChange,
+ isCompareReleaseLoading,
+ isCompareReleaseError,
+ eventWarningCount ?? 0,
+ compareRelease
+ )}
+ />
+ );
+
+ function handleDiffViewChange(diffViewMode: DiffViewMode) {
+ setDiffViewMode(diffViewMode);
+
+ if (latestRevisionNumber === earliestRevisionNumber) {
+ return;
+ }
+
+ // if the input for compare revision number is NaN, set it to the previous revision number
+ if (
+ Number.isNaN(selectedCompareRevisionNumber) &&
+ diffViewMode === 'specific'
+ ) {
+ if (selectedRevision > earliestRevisionNumber) {
+ setSelectedCompareRevisionNumber(selectedRevision - 1);
+ return;
+ }
+ // it could be useful to compare to the latest revision number if the selected revision number is the earliest revision number
+ setSelectedCompareRevisionNumber(latestRevisionNumber);
+ }
+ }
+
+ function setTab(tab: Tab) {
+ router.stateService.go('kubernetes.helm', {
+ tab,
+ });
+ }
+}
function helmTabs(
release: HelmRelease,
isUserSupplied: boolean,
- setIsUserSupplied: (isUserSupplied: boolean) => void
+ setIsUserSupplied: (isUserSupplied: boolean) => void,
+ earliestRevisionNumber: number,
+ latestRevisionNumber: number,
+ selectedRevisionNumber: number,
+ compareRevisionNumber: number,
+ setCompareRevisionNumber: (compareRevisionNumber: number) => void,
+ diffViewMode: DiffViewMode,
+ setDiffViewMode: (diffViewMode: DiffViewMode) => void,
+ isCompareReleaseLoading: boolean,
+ isCompareReleaseError: boolean,
+ eventWarningCount: number,
+ compareRelease?: HelmRelease
): Option[] {
+ // as long as the latest revision number is greater than the earliest revision number, there are changes to compare
+ const showDiffControl = latestRevisionNumber > earliestRevisionNumber;
+
return compact([
{
label: 'Resources',
id: 'resources',
children: ,
},
+ {
+ label: (
+ <>
+ Events
+ {eventWarningCount >= 1 && (
+
+
+ {eventWarningCount}
+
+ )}
+ >
+ ),
+ id: 'events',
+ children: (
+
+ ),
+ },
{
label: 'Values',
id: 'values',
@@ -34,35 +186,97 @@ function helmTabs(
+ }
/>
),
},
{
label: 'Manifest',
id: 'manifest',
- children: ,
+ children: (
+
+ )
+ }
+ />
+ ),
},
!!release.info?.notes && {
label: 'Notes',
id: 'notes',
- children: ,
+ children: (
+
+ )
+ }
+ />
+ ),
},
]);
}
-export function ReleaseTabs({ release }: Props) {
- const [tab, setTab] = useState('resources');
- // state is here so that the state isn't lost when the tab changes
- const [isUserSupplied, setIsUserSupplied] = useState(true);
-
- return (
-
- onSelect={setTab}
- selectedId={tab}
- type="pills"
- justified
- options={helmTabs(release, isUserSupplied, setIsUserSupplied)}
- />
- );
+function parseValidTab(tab: string, hasNotes: boolean): Tab {
+ if (
+ tab === 'values' ||
+ (tab === 'notes' && hasNotes) ||
+ tab === 'manifest' ||
+ tab === 'resources' ||
+ tab === 'events'
+ ) {
+ return tab;
+ }
+ return 'resources';
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx
index a1510aef8..4442ca871 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/DescribeModal.test.tsx
@@ -6,7 +6,17 @@ import { DescribeModal } from './DescribeModal';
const mockUseDescribeResource = vi.fn();
-vi.mock('yaml-schema', () => ({}));
+// Mock the CodeEditor component instead of yaml-schema
+vi.mock('@@/CodeEditor', () => ({
+ CodeEditor: ({
+ value,
+ 'data-cy': dataCy,
+ }: {
+ value: string;
+ 'data-cy'?: string;
+ }) => {value}
,
+}));
+
vi.mock('./queries/useDescribeResource', () => ({
useDescribeResource: (...args: unknown[]) => mockUseDescribeResource(...args),
}));
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
index 6a3b35674..8469e38fe 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/ResourcesTable.tsx
@@ -11,6 +11,7 @@ import {
import { useTableState } from '@@/datatables/useTableState';
import { Widget } from '@@/Widget';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
+import { TextTip } from '@@/Tip/TextTip';
import { useHelmRelease } from '../../queries/useHelmRelease';
@@ -34,12 +35,14 @@ const settingsStore = createStore('helm-resources');
export function ResourcesTable() {
const environmentId = useEnvironmentId();
const { params } = useCurrentStateAndParams();
- const { name, namespace } = params;
+ const { name, namespace, revision } = params;
+ const revisionNumber = revision ? parseInt(revision, 10) : undefined;
const tableState = useTableState(settingsStore, storageKey);
const helmReleaseQuery = useHelmRelease(environmentId, name, namespace, {
showResources: true,
refetchInterval: tableState.autoRefreshRate * 1000,
+ revision: revisionNumber,
});
const rows = useResourceRows(helmReleaseQuery.data?.info?.resources);
@@ -48,11 +51,17 @@ export function ResourcesTable() {
+ Resources reflect the latest revision only.
+
+ }
disableSelect
getRowId={(row) => row.id}
data-cy="helm-resources-datatable"
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx
index b9ff43fff..cded59796 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ResourcesTable/columns/status.tsx
@@ -14,5 +14,9 @@ export const status = columnHelper.accessor((row) => row.status.label, {
function Cell({ row }: CellContext) {
const { status } = row.original;
+ if (!status.label) {
+ return '-';
+ }
+
return {status.label} ;
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx
index b1f32ca3f..7a480660c 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/ValuesDetails.tsx
@@ -1,44 +1,74 @@
-import { Checkbox } from '@@/form-components/Checkbox';
+import { ReactNode } from 'react';
+
import { CodeEditor } from '@@/CodeEditor';
import { Values } from '../../types';
+import { DiffViewMode } from './DiffControl';
+import { DiffViewSection } from './DiffViewSection';
+import { SelectedRevisionNumber, CompareRevisionNumberFetched } from './types';
+
interface Props {
values?: Values;
isUserSupplied: boolean;
- setIsUserSupplied: (isUserSupplied: boolean) => void;
+ selectedRevisionNumber: SelectedRevisionNumber;
+ diffViewMode: DiffViewMode;
+ compareValues?: Values;
+ compareRevisionNumberFetched?: CompareRevisionNumberFetched;
+ isCompareReleaseLoading: boolean;
+ isCompareReleaseError: boolean;
+ diffControl: ReactNode;
}
-const noValuesMessage = 'No values found';
-
export function ValuesDetails({
values,
isUserSupplied,
- setIsUserSupplied,
+ selectedRevisionNumber,
+ diffViewMode,
+ compareValues,
+ compareRevisionNumberFetched,
+ isCompareReleaseLoading,
+ isCompareReleaseError,
+ diffControl,
}: Props) {
return (
-
- {/* bring in line with the code editor copy button */}
-
- setIsUserSupplied(!isUserSupplied)}
- data-cy="values-details-user-supplied"
+ <>
+ {diffControl}
+ {diffViewMode === 'view' ? (
+
-
-
-
+ ) : (
+
+ )}
+ >
);
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/types.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/types.ts
new file mode 100644
index 000000000..0b038a0f5
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/types.ts
@@ -0,0 +1,26 @@
+// exporting as types here allows the JSDocs to be reused, improving readability
+
+/**
+ * The revision number of the latest release.
+ */
+export type LatestRevisionNumber = number;
+
+/**
+ * The revision number selected in the UI.
+ */
+export type SelectedRevisionNumber = number;
+
+/**
+ * The revision number to compare with.
+ */
+export type CompareRevisionNumber = number;
+
+/**
+ * The earliest revision number available for the chart.
+ */
+export type EarliestRevisionNumber = number;
+
+/**
+ * The revision number that's being fetched (instead of the form state).
+ */
+export type CompareRevisionNumberFetched = number;
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts
new file mode 100644
index 000000000..814f03a6a
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/useHelmReleaseToCompare.ts
@@ -0,0 +1,60 @@
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { HelmRelease } from '../../types';
+import { useHelmRelease } from '../queries/useHelmRelease';
+
+import { DiffViewMode } from './DiffControl';
+
+/** useHelmReleaseToCompare is a hook that returns the release to compare to based on the diffViewMode, selectedRevisionNumber and selectedCompareRevisionNumber */
+export function useHelmReleaseToCompare(
+ release: HelmRelease,
+ earliestRevisionNumber: number,
+ latestRevisionNumber: number,
+ diffViewMode: DiffViewMode,
+ selectedRevisionNumber: number,
+ selectedCompareRevisionNumber: number
+) {
+ const environmentId = useEnvironmentId();
+ // the selectedCompareRevisionNumber is the number selected in the input field, but the compareRevisionNumber is the revision number of the release to compare to
+ const compareRevisionNumber = getCompareReleaseVersion(
+ diffViewMode,
+ selectedRevisionNumber,
+ selectedCompareRevisionNumber
+ );
+ const enabled =
+ compareRevisionNumber <= latestRevisionNumber &&
+ compareRevisionNumber >= earliestRevisionNumber;
+
+ // a 1 hour stale time is nice because past releases are not likely to change
+ const compareReleaseQuery = useHelmRelease(
+ environmentId,
+ release.name,
+ release.namespace ?? '',
+ {
+ showResources: false,
+ enabled,
+ staleTime: 60 * 60 * 1000,
+ revision: compareRevisionNumber,
+ }
+ );
+ return {
+ compareRelease: compareReleaseQuery.data,
+ isCompareReleaseLoading: compareReleaseQuery.isInitialLoading,
+ isCompareReleaseError: compareReleaseQuery.isError,
+ };
+}
+
+// getCompareReleaseVersion is a helper function that returns the revision number that should be fetched based on the diffViewMode, selectedRevisionNumber and selectedCompareRevisionNumber
+function getCompareReleaseVersion(
+ diffViewMode: DiffViewMode,
+ selectedRevisionNumber: number,
+ selectedCompareRevisionNumber: number
+) {
+ if (diffViewMode === 'previous') {
+ return selectedRevisionNumber - 1;
+ }
+ if (diffViewMode === 'specific') {
+ return selectedCompareRevisionNumber;
+ }
+ return selectedRevisionNumber;
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts
new file mode 100644
index 000000000..41d6ab375
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmHistory.ts
@@ -0,0 +1,44 @@
+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 { HelmRelease } from '../../types';
+
+export function useHelmHistory(
+ environmentId: EnvironmentId,
+ name: string,
+ namespace: string
+) {
+ return useQuery(
+ [environmentId, 'helm', 'releases', namespace, name, 'history'],
+ () => getHelmHistory(environmentId, name, namespace),
+ {
+ enabled: !!environmentId && !!name && !!namespace,
+ ...withGlobalError('Unable to retrieve helm application history'),
+ retry: 3,
+ // occasionally the application shows before the release is created, take some more time to refetch
+ retryDelay: 2000,
+ }
+ );
+}
+
+async function getHelmHistory(
+ environmentId: EnvironmentId,
+ name: string,
+ namespace: string
+) {
+ try {
+ const response = await axios.get(
+ `endpoints/${environmentId}/kubernetes/helm/${name}/history`,
+ {
+ params: { namespace },
+ }
+ );
+
+ return response.data;
+ } catch (error) {
+ throw parseAxiosError(error, 'Unable to retrieve helm application history');
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
index 8d41e6544..cdf465c16 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRelease.ts
@@ -6,6 +6,15 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { HelmRelease } from '../../types';
+type Options = {
+ select?: (data: HelmRelease) => T;
+ showResources?: boolean;
+ refetchInterval?: number;
+ enabled?: boolean;
+ staleTime?: number;
+ /** when the revision is undefined, the latest revision is fetched */
+ revision?: number;
+};
/**
* React hook to fetch a specific Helm release
*/
@@ -13,39 +22,52 @@ export function useHelmRelease(
environmentId: EnvironmentId,
name: string,
namespace: string,
- options: {
- select?: (data: HelmRelease) => T;
- showResources?: boolean;
- refetchInterval?: number;
- } = {}
+ options: Options = {}
) {
- const { select, showResources, refetchInterval } = options;
+ const { select, showResources, refetchInterval, revision, staleTime } =
+ options;
return useQuery(
- [environmentId, 'helm', 'releases', namespace, name, options.showResources],
+ [
+ environmentId,
+ 'helm',
+ 'releases',
+ namespace,
+ name,
+ revision,
+ showResources,
+ ],
() =>
getHelmRelease(environmentId, name, {
namespace,
showResources,
+ revision,
}),
{
- enabled: !!environmentId && !!name && !!namespace,
+ enabled: !!environmentId && !!name && !!namespace && options.enabled,
...withGlobalError('Unable to retrieve helm application details'),
+ retry: 3,
+ // occasionally the application shows before the release is created, take some more time to refetch
+ retryDelay: 2000,
select,
refetchInterval,
+ staleTime,
}
);
}
+type Params = {
+ namespace: string;
+ showResources?: boolean;
+ revision?: number;
+};
+
/**
* Get a specific Helm release
*/
async function getHelmRelease(
environmentId: EnvironmentId,
name: string,
- params: {
- namespace: string;
- showResources?: boolean;
- }
+ params: Params
) {
try {
const { data } = await axios.get(
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts
new file mode 100644
index 000000000..733b79dea
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts
@@ -0,0 +1,99 @@
+import { useQuery, useQueries } from '@tanstack/react-query';
+import { useMemo } from 'react';
+import { compact, flatMap } from 'lodash';
+
+import { withGlobalError } from '@/react-tools/react-query';
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { useCurrentUser } from '@/react/hooks/useUser';
+
+import { getHelmRepositories } from '../../queries/useHelmChartList';
+
+interface HelmSearch {
+ entries: Entries;
+}
+
+interface Entries {
+ [key: string]: { version: string }[];
+}
+
+export interface ChartVersion {
+ Repo: string;
+ Version: string;
+}
+
+/**
+ * Hook to fetch all Helm repositories for the current user
+ */
+export function useHelmRepositories() {
+ const { user } = useCurrentUser();
+ return useQuery(
+ ['helm', 'repositories'],
+ async () => getHelmRepositories(user.Id),
+ {
+ enabled: !!user.Id,
+ ...withGlobalError('Unable to retrieve helm repositories'),
+ }
+ );
+}
+
+/**
+ * React hook to get a list of available versions for a chart from specified repositories
+ *
+ * @param chart The chart name to get versions for
+ * @param repositories Array of repository URLs to search in
+ */
+export function useHelmRepoVersions(
+ chart: string,
+ staleTime: number,
+ repositories: string[] = []
+) {
+ // Fetch versions from each repository in parallel as separate queries
+ const versionQueries = useQueries({
+ queries: useMemo(
+ () =>
+ repositories.map((repo) => ({
+ queryKey: ['helm', 'repositories', chart, repo],
+ queryFn: () => getSearchHelmRepo(repo, chart),
+ enabled: !!chart && repositories.length > 0,
+ staleTime,
+ ...withGlobalError(`Unable to retrieve versions from ${repo}`),
+ })),
+ [repositories, chart, staleTime]
+ ),
+ });
+
+ // Combine the results from all repositories for easier consumption
+ const allVersions = useMemo(() => {
+ const successfulResults = compact(versionQueries.map((q) => q.data));
+ return flatMap(successfulResults);
+ }, [versionQueries]);
+
+ return {
+ data: allVersions,
+ isInitialLoading: versionQueries.some((q) => q.isLoading),
+ isError: versionQueries.some((q) => q.isError),
+ };
+}
+
+/**
+ * Get Helm repositories for user
+ */
+async function getSearchHelmRepo(
+ repo: string,
+ chart: string
+): Promise {
+ try {
+ const { data } = await axios.get(`templates/helm`, {
+ params: { repo, chart },
+ });
+ const versions = data.entries[chart];
+ return (
+ versions?.map((v) => ({
+ Repo: repo,
+ Version: v.version,
+ })) ?? []
+ );
+ } catch (err) {
+ throw parseAxiosError(err, 'Unable to retrieve helm repositories for user');
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useUpdateHelmReleaseMutation.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useUpdateHelmReleaseMutation.ts
new file mode 100644
index 000000000..00e9f8131
--- /dev/null
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useUpdateHelmReleaseMutation.ts
@@ -0,0 +1,44 @@
+import { useQueryClient, useMutation } from '@tanstack/react-query';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
+import { queryKeys as applicationsQueryKeys } from '@/react/kubernetes/applications/queries/query-keys';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { HelmRelease } from '../../types';
+
+export interface UpdateHelmReleasePayload {
+ namespace: string;
+ values?: string;
+ repo?: string;
+ name: string;
+ chart: string;
+ version?: string;
+}
+export function useUpdateHelmReleaseMutation(environmentId: EnvironmentId) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (payload: UpdateHelmReleasePayload) =>
+ updateHelmRelease(environmentId, payload),
+ ...withInvalidate(queryClient, [
+ [environmentId, 'helm', 'releases'],
+ applicationsQueryKeys.applications(environmentId),
+ ]),
+ ...withGlobalError('Unable to uninstall helm application'),
+ });
+}
+
+async function updateHelmRelease(
+ environmentId: EnvironmentId,
+ payload: UpdateHelmReleasePayload
+) {
+ try {
+ const { data } = await axios.post(
+ `endpoints/${environmentId}/kubernetes/helm`,
+ payload
+ );
+ return data;
+ } catch (err) {
+ throw parseAxiosError(err, 'Unable to update helm release');
+ }
+}
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx
index c8def2077..5fb88d64c 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplates.tsx
@@ -3,8 +3,11 @@ import { useState } from 'react';
import { useCurrentUser } from '@/react/hooks/useUser';
import { Chart } from '../types';
+import {
+ useHelmChartList,
+ useHelmRepositories,
+} from '../queries/useHelmChartList';
-import { useHelmChartList } from './queries/useHelmChartList';
import { HelmTemplatesList } from './HelmTemplatesList';
import { HelmTemplatesSelectedItem } from './HelmTemplatesSelectedItem';
@@ -18,10 +21,8 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
const [selectedChart, setSelectedChart] = useState(null);
const { user } = useCurrentUser();
- const { data: charts = [], isLoading: chartsLoading } = useHelmChartList(
- user.Id
- );
-
+ const helmReposQuery = useHelmRepositories(user.Id);
+ const chartListQuery = useHelmChartList(user.Id, helmReposQuery.data ?? []);
function clearHelmChart() {
setSelectedChart(null);
onSelectHelmChart('');
@@ -44,9 +45,9 @@ export function HelmTemplates({ onSelectHelmChart, namespace, name }: Props) {
/>
) : (
)}
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx
index 3c080d077..f02d83131 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.test.tsx
@@ -50,7 +50,7 @@ function renderComponent({
withUserProvider(
withTestRouter(() => (
@@ -137,10 +137,10 @@ describe('HelmTemplatesList', () => {
});
it('should show loading message when loading prop is true', async () => {
- renderComponent({ loading: true });
+ renderComponent({ loading: true, charts: [] });
// Check for loading message
- expect(screen.getByText('Loading...')).toBeInTheDocument();
+ expect(screen.getByText('Loading helm charts...')).toBeInTheDocument();
expect(
screen.getByText('Initial download of Helm charts can take a few minutes')
).toBeInTheDocument();
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
index 3d9034f4c..c41b0acbb 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesList.tsx
@@ -5,13 +5,14 @@ import { Link } from '@/react/components/Link';
import { InsightsBox } from '@@/InsightsBox';
import { SearchBar } from '@@/datatables/SearchBar';
+import { InlineLoader } from '@@/InlineLoader';
import { Chart } from '../types';
import { HelmTemplatesListItem } from './HelmTemplatesListItem';
interface Props {
- loading: boolean;
+ isLoading: boolean;
charts?: Chart[];
selectAction: (chart: Chart) => void;
}
@@ -70,7 +71,7 @@ function getFilteredCharts(
}
export function HelmTemplatesList({
- loading,
+ isLoading,
charts = [],
selectAction,
}: Props) {
@@ -159,16 +160,20 @@ export function HelmTemplatesList({
No Helm charts found
)}
- {loading && (
-
- Loading...
-
- Initial download of Helm charts can take a few minutes
-
+ {isLoading && (
+
+
+ Loading helm charts...
+
+ {charts.length === 0 && (
+
+ Initial download of Helm charts can take a few minutes
+
+ )}
)}
- {!loading && charts.length === 0 && (
+ {!isLoading && charts.length === 0 && (
No helm charts available.
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
index 655b1d928..215838ff8 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
@@ -106,10 +106,7 @@ describe('HelmTemplatesSelectedItem', () => {
renderComponent();
const user = userEvent.setup();
- // First show the editor
- await user.click(await screen.findByText('Custom values'));
-
- // Verify editor is visible
+ // Verify editor is visible by default
expect(screen.getByTestId('helm-app-creation-editor')).toBeInTheDocument();
// Now hide the editor
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
index 088c0c69a..baffb9655 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
@@ -143,7 +143,12 @@ export function HelmTemplatesSelectedItem({
{({ values, setFieldValue }) => (
+ }
+ />
+
+ )}
+ {isNewVersionAvailable && (
+
+ New version available ({latestVersionAvailable})
)}
@@ -185,18 +164,4 @@ export function UpgradeButton({
},
});
}
-
- function getStatusMessage(
- hasNoAvailableVersions: boolean,
- latestVersionAvailable: string,
- isNewVersionAvailable: boolean
- ): string {
- if (hasNoAvailableVersions) {
- return 'No versions available ';
- }
- if (isNewVersionAvailable) {
- return `New version available (${latestVersionAvailable}) `;
- }
- return '';
- }
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts
index bf11eadb1..733b79dea 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts
@@ -45,21 +45,20 @@ export function useHelmRepositories() {
export function useHelmRepoVersions(
chart: string,
staleTime: number,
- repositories: string[] = [],
- useCache: boolean = true
+ repositories: string[] = []
) {
// Fetch versions from each repository in parallel as separate queries
const versionQueries = useQueries({
queries: useMemo(
() =>
repositories.map((repo) => ({
- queryKey: ['helm', 'repositories', chart, repo, useCache],
- queryFn: () => getSearchHelmRepo(repo, chart, useCache),
+ queryKey: ['helm', 'repositories', chart, repo],
+ queryFn: () => getSearchHelmRepo(repo, chart),
enabled: !!chart && repositories.length > 0,
staleTime,
...withGlobalError(`Unable to retrieve versions from ${repo}`),
})),
- [repositories, chart, staleTime, useCache]
+ [repositories, chart, staleTime]
),
});
@@ -73,8 +72,6 @@ export function useHelmRepoVersions(
data: allVersions,
isInitialLoading: versionQueries.some((q) => q.isLoading),
isError: versionQueries.some((q) => q.isError),
- isFetching: versionQueries.some((q) => q.isFetching),
- refetch: () => Promise.all(versionQueries.map((q) => q.refetch())),
};
}
@@ -83,12 +80,11 @@ export function useHelmRepoVersions(
*/
async function getSearchHelmRepo(
repo: string,
- chart: string,
- useCache: boolean = true
+ chart: string
): Promise {
try {
const { data } = await axios.get(`templates/helm`, {
- params: { repo, chart, useCache },
+ params: { repo, chart },
});
const versions = data.entries[chart];
return (
diff --git a/pkg/libhelm/options/search_repo_options.go b/pkg/libhelm/options/search_repo_options.go
index 0b35c0bbd..73acc5709 100644
--- a/pkg/libhelm/options/search_repo_options.go
+++ b/pkg/libhelm/options/search_repo_options.go
@@ -3,8 +3,6 @@ package options
import "net/http"
type SearchRepoOptions struct {
- Repo string `example:"https://charts.gitlab.io/"`
- Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"`
- Chart string `example:"my-chart"`
- UseCache bool `example:"false"`
+ Repo string `example:"https://charts.gitlab.io/"`
+ Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"`
}
diff --git a/pkg/libhelm/sdk/search_repo.go b/pkg/libhelm/sdk/search_repo.go
index 63015330c..90dd98529 100644
--- a/pkg/libhelm/sdk/search_repo.go
+++ b/pkg/libhelm/sdk/search_repo.go
@@ -4,8 +4,6 @@ import (
"net/url"
"os"
"path/filepath"
- "sync"
- "time"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
@@ -27,17 +25,6 @@ type RepoIndex struct {
Generated string `json:"generated"`
}
-type RepoIndexCache struct {
- Index *repo.IndexFile
- Timestamp time.Time
-}
-
-var (
- indexCache = make(map[string]RepoIndexCache)
- cacheMutex sync.RWMutex
- cacheDuration = 60 * time.Minute
-)
-
// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller.
func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
// Validate input options
@@ -66,18 +53,6 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
return nil, err
}
- // Check cache first
- if searchRepoOpts.UseCache {
- cacheMutex.RLock()
- if cached, exists := indexCache[repoURL.String()]; exists {
- if time.Since(cached.Timestamp) < cacheDuration {
- cacheMutex.RUnlock()
- return convertAndMarshalIndex(cached.Index, searchRepoOpts.Chart)
- }
- }
- cacheMutex.RUnlock()
- }
-
// Set up Helm CLI environment
repoSettings := cli.New()
@@ -117,21 +92,23 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
return nil, err
}
- // Update cache and remove old entries
- cacheMutex.Lock()
- indexCache[searchRepoOpts.Repo] = RepoIndexCache{
- Index: indexFile,
- Timestamp: time.Now(),
- }
- for key, index := range indexCache {
- if time.Since(index.Timestamp) > cacheDuration {
- delete(indexCache, key)
- }
+ // Convert the index file to our response format
+ result, err := convertIndexToResponse(indexFile)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to convert index to response format")
+ return nil, errors.Wrap(err, "failed to convert index to response format")
}
- cacheMutex.Unlock()
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("repo", searchRepoOpts.Repo).
+ Int("entries_count", len(indexFile.Entries)).
+ Msg("Successfully searched repository")
- return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart)
+ return json.Marshal(result)
}
// validateSearchRepoOptions validates the required search repository options.
@@ -239,7 +216,7 @@ func loadIndexFile(indexPath string) (*repo.IndexFile, error) {
}
// convertIndexToResponse converts the Helm index file to our response format.
-func convertIndexToResponse(indexFile *repo.IndexFile, chartName string) (RepoIndex, error) {
+func convertIndexToResponse(indexFile *repo.IndexFile) (RepoIndex, error) {
result := RepoIndex{
APIVersion: indexFile.APIVersion,
Entries: make(map[string][]ChartInfo),
@@ -248,9 +225,7 @@ func convertIndexToResponse(indexFile *repo.IndexFile, chartName string) (RepoIn
// Convert Helm SDK types to our response types
for name, charts := range indexFile.Entries {
- if chartName == "" || name == chartName {
- result.Entries[name] = convertChartsToChartInfo(charts)
- }
+ result.Entries[name] = convertChartsToChartInfo(charts)
}
return result, nil
@@ -374,23 +349,3 @@ func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
return nil
}
-
-func convertAndMarshalIndex(indexFile *repo.IndexFile, chartName string) ([]byte, error) {
- // Convert the index file to our response format
- result, err := convertIndexToResponse(indexFile, chartName)
- if err != nil {
- log.Error().
- Str("context", "HelmClient").
- Err(err).
- Msg("Failed to convert index to response format")
- return nil, errors.Wrap(err, "failed to convert index to response format")
- }
-
- log.Debug().
- Str("context", "HelmClient").
- Str("repo", chartName).
- Int("entries_count", len(indexFile.Entries)).
- Msg("Successfully searched repository")
-
- return json.Marshal(result)
-}
From 07dfd981a21279518f3dbae2429c4e0f091e1648 Mon Sep 17 00:00:00 2001
From: Cara Ryan
Date: Tue, 27 May 2025 13:55:31 +1200
Subject: [PATCH 132/212] fix(kubernetes): events api to call the backend
[R8S-243] (#563)
---
api/http/handler/kubernetes/client.go | 4 +-
.../kubernetes/cluster_role_bindings.go | 2 +-
api/http/handler/kubernetes/cluster_roles.go | 2 +-
api/http/handler/kubernetes/event.go | 102 +++++++++
api/http/handler/kubernetes/event_test.go | 60 ++++++
api/http/handler/kubernetes/handler.go | 6 +-
api/http/models/kubernetes/event.go | 25 +++
api/internal/testhelpers/kube_client.go | 19 ++
api/kubernetes/cli/access.go | 20 ++
api/kubernetes/cli/client.go | 4 +
api/kubernetes/cli/event.go | 93 ++++++++
api/kubernetes/cli/event_test.go | 108 ++++++++++
api/portainer.go | 147 +++++++++----
.../configmap/edit/configMap.html | 2 +-
.../configurations/secret/edit/secret.html | 2 +-
.../EventsDatatable/EventsDatatable.test.tsx | 83 +++++++
.../EventsDatatable/EventsDatatable.tsx | 4 +-
.../ResourceEventsDatatable.tsx | 4 +-
.../EventsDatatable/columns/eventType.tsx | 3 +-
.../EventsDatatable/columns/helper.ts | 3 +-
.../EventsDatatable/columns/kind.tsx | 3 +-
.../HelmApplicationView.test.tsx | 33 +--
.../HelmEventsDatatable.test.tsx | 202 +++++++-----------
.../ReleaseDetails/HelmEventsDatatable.tsx | 2 +-
app/react/kubernetes/queries/types.ts | 19 ++
app/react/kubernetes/queries/useEvents.ts | 15 +-
26 files changed, 750 insertions(+), 217 deletions(-)
create mode 100644 api/http/handler/kubernetes/event.go
create mode 100644 api/http/handler/kubernetes/event_test.go
create mode 100644 api/http/models/kubernetes/event.go
create mode 100644 api/internal/testhelpers/kube_client.go
create mode 100644 api/kubernetes/cli/event.go
create mode 100644 api/kubernetes/cli/event_test.go
create mode 100644 app/react/kubernetes/components/EventsDatatable/EventsDatatable.test.tsx
create mode 100644 app/react/kubernetes/queries/types.ts
diff --git a/api/http/handler/kubernetes/client.go b/api/http/handler/kubernetes/client.go
index a85f2cff9..6612ab7f4 100644
--- a/api/http/handler/kubernetes/client.go
+++ b/api/http/handler/kubernetes/client.go
@@ -30,8 +30,8 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht
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)
}
- pcli.IsKubeAdmin = cli.IsKubeAdmin
- pcli.NonAdminNamespaces = cli.NonAdminNamespaces
+ pcli.SetIsKubeAdmin(cli.GetIsKubeAdmin())
+ pcli.SetClientNonAdminNamespaces(cli.GetClientNonAdminNamespaces())
return pcli, nil
}
diff --git a/api/http/handler/kubernetes/cluster_role_bindings.go b/api/http/handler/kubernetes/cluster_role_bindings.go
index a5050c947..83621a900 100644
--- a/api/http/handler/kubernetes/cluster_role_bindings.go
+++ b/api/http/handler/kubernetes/cluster_role_bindings.go
@@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoleBindings(w http.ResponseWrite
return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", httpErr)
}
- if !cli.IsKubeAdmin {
+ if !cli.GetIsKubeAdmin() {
log.Error().Str("context", "getAllKubernetesClusterRoleBindings").Msg("user is not authorized to fetch cluster role bindings from the Kubernetes cluster.")
return httperror.Forbidden("User is not authorized to fetch cluster role bindings from the Kubernetes cluster.", nil)
}
diff --git a/api/http/handler/kubernetes/cluster_roles.go b/api/http/handler/kubernetes/cluster_roles.go
index 3fd2ca8aa..6d5d028be 100644
--- a/api/http/handler/kubernetes/cluster_roles.go
+++ b/api/http/handler/kubernetes/cluster_roles.go
@@ -32,7 +32,7 @@ func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *h
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", httpErr)
}
- if !cli.IsKubeAdmin {
+ if !cli.GetIsKubeAdmin() {
log.Error().Str("context", "getAllKubernetesClusterRoles").Msg("user is not authorized to fetch cluster roles from the Kubernetes cluster.")
return httperror.Forbidden("User is not authorized to fetch cluster roles from the Kubernetes cluster.", nil)
}
diff --git a/api/http/handler/kubernetes/event.go b/api/http/handler/kubernetes/event.go
new file mode 100644
index 000000000..0e226d5ec
--- /dev/null
+++ b/api/http/handler/kubernetes/event.go
@@ -0,0 +1,102 @@
+package kubernetes
+
+import (
+ "net/http"
+
+ httperror "github.com/portainer/portainer/pkg/libhttp/error"
+ "github.com/portainer/portainer/pkg/libhttp/request"
+ "github.com/portainer/portainer/pkg/libhttp/response"
+ "github.com/rs/zerolog/log"
+ k8serrors "k8s.io/apimachinery/pkg/api/errors"
+)
+
+// @id getKubernetesEventsForNamespace
+// @summary Gets kubernetes events for namespace
+// @description Get events by optional query param resourceId for a given namespace.
+// @description **Access policy**: Authenticated user.
+// @tags kubernetes
+// @security ApiKeyAuth || jwt
+// @produce json
+// @param id path int true "Environment identifier"
+// @param namespace path string true "The namespace name the events are associated to"
+// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
+// @success 200 {object} models.Event[] "Success"
+// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
+// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
+// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
+// @failure 500 "Server error occurred while attempting to retrieve the events within the specified namespace."
+// @router /kubernetes/{id}/namespaces/{namespace}/events [get]
+func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+ namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
+ if err != nil {
+ log.Error().Err(err).Str("context", "getKubernetesEvents").Str("namespace", namespace).Msg("Unable to retrieve namespace identifier route variable")
+ return httperror.BadRequest("Unable to retrieve namespace identifier route variable", err)
+ }
+
+ resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true)
+ if err != nil {
+ log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter")
+ return httperror.BadRequest("Unable to retrieve resourceId query parameter", err)
+ }
+
+ cli, httpErr := handler.getProxyKubeClient(r)
+ if httpErr != nil {
+ log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user")
+ return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
+ }
+
+ events, err := cli.GetEvents(namespace, resourceId)
+ if err != nil {
+ if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
+ log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API")
+ return httperror.Forbidden("Unauthorized access to the Kubernetes API", err)
+ }
+
+ log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events")
+ return httperror.InternalServerError("Unable to retrieve events", err)
+ }
+
+ return response.JSON(w, events)
+}
+
+// @id getAllKubernetesEvents
+// @summary Gets kubernetes events
+// @description Get events by query param resourceId
+// @description **Access policy**: Authenticated user.
+// @tags kubernetes
+// @security ApiKeyAuth || jwt
+// @produce json
+// @param id path int true "Environment identifier"
+// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
+// @success 200 {object} models.Event[] "Success"
+// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
+// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
+// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
+// @failure 500 "Server error occurred while attempting to retrieve the events."
+// @router /kubernetes/{id}/events [get]
+func (handler *Handler) getAllKubernetesEvents(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+ resourceId, err := request.RetrieveQueryParameter(r, "resourceId", true)
+ if err != nil {
+ log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve resourceId query parameter")
+ return httperror.BadRequest("Unable to retrieve resourceId query parameter", err)
+ }
+
+ cli, httpErr := handler.getProxyKubeClient(r)
+ if httpErr != nil {
+ log.Error().Err(httpErr).Str("context", "getKubernetesEvents").Str("resourceId", resourceId).Msg("Unable to get a Kubernetes client for the user")
+ return httperror.InternalServerError("Unable to get a Kubernetes client for the user", httpErr)
+ }
+
+ events, err := cli.GetEvents("", resourceId)
+ if err != nil {
+ if k8serrors.IsUnauthorized(err) || k8serrors.IsForbidden(err) {
+ log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unauthorized access to the Kubernetes API")
+ return httperror.Forbidden("Unauthorized access to the Kubernetes API", err)
+ }
+
+ log.Error().Err(err).Str("context", "getKubernetesEvents").Msg("Unable to retrieve events")
+ return httperror.InternalServerError("Unable to retrieve events", err)
+ }
+
+ return response.JSON(w, events)
+}
diff --git a/api/http/handler/kubernetes/event_test.go b/api/http/handler/kubernetes/event_test.go
new file mode 100644
index 000000000..77f38c511
--- /dev/null
+++ b/api/http/handler/kubernetes/event_test.go
@@ -0,0 +1,60 @@
+package kubernetes
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/datastore"
+ "github.com/portainer/portainer/api/http/security"
+ "github.com/portainer/portainer/api/internal/authorization"
+ "github.com/portainer/portainer/api/internal/testhelpers"
+ "github.com/portainer/portainer/api/jwt"
+ "github.com/portainer/portainer/api/kubernetes"
+ kubeClient "github.com/portainer/portainer/api/kubernetes/cli"
+ "github.com/stretchr/testify/assert"
+)
+
+// Currently this test just tests the HTTP Handler is setup correctly, in the future we should move the ClientFactory to a mock in order
+// test the logic in event.go
+func TestGetKubernetesEvents(t *testing.T) {
+ is := assert.New(t)
+
+ _, store := datastore.MustNewTestStore(t, true, true)
+
+ err := store.Endpoint().Create(&portainer.Endpoint{
+ ID: 1,
+ Type: portainer.AgentOnKubernetesEnvironment,
+ },
+ )
+ is.NoError(err, "error creating environment")
+
+ err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
+ is.NoError(err, "error creating a user")
+
+ jwtService, err := jwt.NewService("1h", store)
+ is.NoError(err, "Error initiating jwt service")
+
+ tk, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: 1, Username: "admin", Role: portainer.AdministratorRole})
+
+ kubeClusterAccessService := kubernetes.NewKubeClusterAccessService("", "", "")
+
+ cli := testhelpers.NewKubernetesClient()
+ factory, _ := kubeClient.NewClientFactory(nil, nil, store, "", "", "")
+
+ authorizationService := authorization.NewService(store)
+ handler := NewHandler(testhelpers.NewTestRequestBouncer(), authorizationService, store, jwtService, kubeClusterAccessService,
+ factory, cli)
+ is.NotNil(handler, "Handler should not fail")
+
+ req := httptest.NewRequest(http.MethodGet, "/kubernetes/1/events?resourceId=8", nil)
+ ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
+ req = req.WithContext(ctx)
+ testhelpers.AddTestSecurityCookie(req, tk)
+
+ rr := httptest.NewRecorder()
+ handler.ServeHTTP(rr, req)
+
+ is.Equal(http.StatusOK, rr.Code, "Status should be 200")
+}
diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go
index cc068e4a4..07f6bdf3f 100644
--- a/api/http/handler/kubernetes/handler.go
+++ b/api/http/handler/kubernetes/handler.go
@@ -58,6 +58,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost)
+ endpointRouter.Handle("/events", httperror.LoggerHandler(h.getAllKubernetesEvents)).Methods(http.MethodGet)
endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet)
@@ -110,6 +111,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
// to keep it simple, we've decided to leave it like this.
namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
namespaceRouter.Handle("/configmaps/{configmap}", httperror.LoggerHandler(h.getKubernetesConfigMap)).Methods(http.MethodGet)
+ namespaceRouter.Handle("/events", httperror.LoggerHandler(h.getKubernetesEventsForNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.getKubernetesIngressControllersByNamespace)).Methods(http.MethodGet)
namespaceRouter.Handle("/ingresscontrollers", httperror.LoggerHandler(h.updateKubernetesIngressControllersByNamespace)).Methods(http.MethodPut)
@@ -133,7 +135,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
// getProxyKubeClient gets a kubeclient for the user. It's generally what you want as it retrieves the kubeclient
// from the Authorization token of the currently logged in user. The kubeclient that is not from the proxy is actually using
// admin permissions. If you're unsure which one to use, use this.
-func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperror.HandlerError) {
+func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *httperror.HandlerError) {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return nil, httperror.BadRequest(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, the environment identifier route variable is invalid for /api/kubernetes/%d. Error: ", endpointID), err)
@@ -253,7 +255,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return
}
serverURL.Scheme = "https"
- serverURL.Host = "localhost" + handler.KubernetesClientFactory.AddrHTTPS
+ serverURL.Host = "localhost" + handler.KubernetesClientFactory.GetAddrHTTPS()
config.Clusters[0].Cluster.Server = serverURL.String()
yaml, err := cli.GenerateYAML(config)
diff --git a/api/http/models/kubernetes/event.go b/api/http/models/kubernetes/event.go
new file mode 100644
index 000000000..be447b554
--- /dev/null
+++ b/api/http/models/kubernetes/event.go
@@ -0,0 +1,25 @@
+package kubernetes
+
+import "time"
+
+type K8sEvent struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Reason string `json:"reason"`
+ Message string `json:"message"`
+ Namespace string `json:"namespace"`
+ EventTime time.Time `json:"eventTime"`
+ Kind string `json:"kind,omitempty"`
+ Count int32 `json:"count"`
+ FirstTimestamp *time.Time `json:"firstTimestamp,omitempty"`
+ LastTimestamp *time.Time `json:"lastTimestamp,omitempty"`
+ UID string `json:"uid"`
+ InvolvedObjectKind K8sEventInvolvedObject `json:"involvedObject"`
+}
+
+type K8sEventInvolvedObject struct {
+ Kind string `json:"kind,omitempty"`
+ UID string `json:"uid"`
+ Name string `json:"name"`
+ Namespace string `json:"namespace"`
+}
diff --git a/api/internal/testhelpers/kube_client.go b/api/internal/testhelpers/kube_client.go
new file mode 100644
index 000000000..550e7ce92
--- /dev/null
+++ b/api/internal/testhelpers/kube_client.go
@@ -0,0 +1,19 @@
+package testhelpers
+
+import (
+ portainer "github.com/portainer/portainer/api"
+ models "github.com/portainer/portainer/api/http/models/kubernetes"
+)
+
+type testKubeClient struct {
+ portainer.KubeClient
+}
+
+func NewKubernetesClient() portainer.KubeClient {
+ return &testKubeClient{}
+}
+
+// Event
+func (kcl *testKubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
+ return nil, nil
+}
diff --git a/api/kubernetes/cli/access.go b/api/kubernetes/cli/access.go
index 73f8d50af..6f254c296 100644
--- a/api/kubernetes/cli/access.go
+++ b/api/kubernetes/cli/access.go
@@ -143,3 +143,23 @@ func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestri
return nonAdminNamespaces, nil
}
+
+// GetIsKubeAdmin retrieves true if client is admin
+func (client *KubeClient) GetIsKubeAdmin() bool {
+ return client.IsKubeAdmin
+}
+
+// UpdateIsKubeAdmin sets whether the kube client is admin
+func (client *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) {
+ client.IsKubeAdmin = isKubeAdmin
+}
+
+// GetClientNonAdminNamespaces retrieves non-admin namespaces
+func (client *KubeClient) GetClientNonAdminNamespaces() []string {
+ return client.NonAdminNamespaces
+}
+
+// UpdateClientNonAdminNamespaces sets the client non admin namespace list
+func (client *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) {
+ client.NonAdminNamespaces = nonAdminNamespaces
+}
diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go
index 6d2cc437c..a40a865f1 100644
--- a/api/kubernetes/cli/client.go
+++ b/api/kubernetes/cli/client.go
@@ -82,6 +82,10 @@ func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID)
factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID)))
}
+func (factory *ClientFactory) GetAddrHTTPS() string {
+ return factory.AddrHTTPS
+}
+
// GetPrivilegedKubeClient checks if an existing client is already registered for the environment(endpoint) 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) GetPrivilegedKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) {
diff --git a/api/kubernetes/cli/event.go b/api/kubernetes/cli/event.go
new file mode 100644
index 000000000..03472fca6
--- /dev/null
+++ b/api/kubernetes/cli/event.go
@@ -0,0 +1,93 @@
+package cli
+
+import (
+ "context"
+
+ models "github.com/portainer/portainer/api/http/models/kubernetes"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// GetEvents gets all the Events for a given namespace and resource
+// If the user is a kube admin, it returns all events in the namespace
+// Otherwise, it returns only the events in the non-admin namespaces
+func (kcl *KubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
+ if kcl.IsKubeAdmin {
+ return kcl.fetchAllEvents(namespace, resourceId)
+ }
+
+ return kcl.fetchEventsForNonAdmin(namespace, resourceId)
+}
+
+// fetchEventsForNonAdmin returns all events in the given namespace and resource
+// It returns only the events in the non-admin namespaces
+func (kcl *KubeClient) fetchEventsForNonAdmin(namespace string, resourceId string) ([]models.K8sEvent, error) {
+ if len(kcl.NonAdminNamespaces) == 0 {
+ return nil, nil
+ }
+
+ events, err := kcl.fetchAllEvents(namespace, resourceId)
+ if err != nil {
+ return nil, err
+ }
+
+ nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
+ results := make([]models.K8sEvent, 0)
+ for _, event := range events {
+ if _, ok := nonAdminNamespaceSet[event.Namespace]; ok {
+ results = append(results, event)
+ }
+ }
+
+ return results, nil
+}
+
+// fetchEventsForNonAdmin returns all events in the given namespace and resource
+// It returns all events in the namespace and resource
+func (kcl *KubeClient) fetchAllEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
+ options := metav1.ListOptions{}
+ if resourceId != "" {
+ options.FieldSelector = "involvedObject.uid=" + resourceId
+ }
+
+ list, err := kcl.cli.CoreV1().Events(namespace).List(context.TODO(), options)
+ if err != nil {
+ return nil, err
+ }
+
+ results := make([]models.K8sEvent, 0)
+ for _, event := range list.Items {
+ results = append(results, parseEvent(&event))
+ }
+
+ return results, nil
+}
+
+func parseEvent(event *corev1.Event) models.K8sEvent {
+ result := models.K8sEvent{
+ Type: event.Type,
+ Name: event.Name,
+ Message: event.Message,
+ Reason: event.Reason,
+ Namespace: event.Namespace,
+ EventTime: event.EventTime.UTC(),
+ Kind: event.Kind,
+ Count: event.Count,
+ UID: string(event.ObjectMeta.GetUID()),
+ InvolvedObjectKind: models.K8sEventInvolvedObject{
+ Kind: event.InvolvedObject.Kind,
+ UID: string(event.InvolvedObject.UID),
+ Name: event.InvolvedObject.Name,
+ Namespace: event.InvolvedObject.Namespace,
+ },
+ }
+
+ if !event.LastTimestamp.Time.IsZero() {
+ result.LastTimestamp = &event.LastTimestamp.Time
+ }
+ if !event.FirstTimestamp.Time.IsZero() {
+ result.FirstTimestamp = &event.FirstTimestamp.Time
+ }
+
+ return result
+}
diff --git a/api/kubernetes/cli/event_test.go b/api/kubernetes/cli/event_test.go
new file mode 100644
index 000000000..926928317
--- /dev/null
+++ b/api/kubernetes/cli/event_test.go
@@ -0,0 +1,108 @@
+package cli
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ kfake "k8s.io/client-go/kubernetes/fake"
+)
+
+// TestGetEvents tests the GetEvents method
+// It creates a fake Kubernetes client and passes it to the GetEvents method
+// It then logs the fetched events and validated the data returned
+func TestGetEvents(t *testing.T) {
+ t.Run("can get events for resource id when admin", func(t *testing.T) {
+ kcl := &KubeClient{
+ cli: kfake.NewSimpleClientset(),
+ instanceID: "instance",
+ IsKubeAdmin: true,
+ }
+ event := corev1.Event{
+ InvolvedObject: corev1.ObjectReference{UID: "resourceId"},
+ Action: "something",
+ ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "myEvent"},
+ EventTime: metav1.NowMicro(),
+ Type: "warning",
+ Message: "This event has a very serious warning",
+ }
+ _, err := kcl.cli.CoreV1().Events("default").Create(context.TODO(), &event, metav1.CreateOptions{})
+ if err != nil {
+ t.Fatalf("Failed to create Event: %v", err)
+ }
+
+ events, err := kcl.GetEvents("default", "resourceId")
+
+ if err != nil {
+ t.Fatalf("Failed to fetch Cron Jobs: %v", err)
+ }
+ t.Logf("Fetched Events: %v", events)
+ require.Equal(t, 1, len(events), "Expected to return 1 event")
+ assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created")
+ assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created")
+ assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created")
+ })
+ t.Run("can get kubernetes events for non admin namespace when non admin", func(t *testing.T) {
+ kcl := &KubeClient{
+ cli: kfake.NewSimpleClientset(),
+ instanceID: "instance",
+ IsKubeAdmin: false,
+ NonAdminNamespaces: []string{"nonAdmin"},
+ }
+ event := corev1.Event{
+ InvolvedObject: corev1.ObjectReference{UID: "resourceId"},
+ Action: "something",
+ ObjectMeta: metav1.ObjectMeta{Namespace: "nonAdmin", Name: "myEvent"},
+ EventTime: metav1.NowMicro(),
+ Type: "warning",
+ Message: "This event has a very serious warning",
+ }
+ _, err := kcl.cli.CoreV1().Events("nonAdmin").Create(context.TODO(), &event, metav1.CreateOptions{})
+ if err != nil {
+ t.Fatalf("Failed to create Event: %v", err)
+ }
+
+ events, err := kcl.GetEvents("nonAdmin", "resourceId")
+
+ if err != nil {
+ t.Fatalf("Failed to fetch Cron Jobs: %v", err)
+ }
+ t.Logf("Fetched Events: %v", events)
+ require.Equal(t, 1, len(events), "Expected to return 1 event")
+ assert.Equal(t, event.Message, events[0].Message, "Expected Message to be equal to event message created")
+ assert.Equal(t, event.Type, events[0].Type, "Expected Type to be equal to event type created")
+ assert.Equal(t, event.EventTime.UTC(), events[0].EventTime, "Expected EventTime to be saved as a string from event time created")
+ })
+
+ t.Run("cannot get kubernetes events for admin namespace when non admin", func(t *testing.T) {
+ kcl := &KubeClient{
+ cli: kfake.NewSimpleClientset(),
+ instanceID: "instance",
+ IsKubeAdmin: false,
+ NonAdminNamespaces: []string{"nonAdmin"},
+ }
+ event := corev1.Event{
+ InvolvedObject: corev1.ObjectReference{UID: "resourceId"},
+ Action: "something",
+ ObjectMeta: metav1.ObjectMeta{Namespace: "admin", Name: "myEvent"},
+ EventTime: metav1.NowMicro(),
+ Type: "warning",
+ Message: "This event has a very serious warning",
+ }
+ _, err := kcl.cli.CoreV1().Events("admin").Create(context.TODO(), &event, metav1.CreateOptions{})
+ if err != nil {
+ t.Fatalf("Failed to create Event: %v", err)
+ }
+
+ events, err := kcl.GetEvents("admin", "resourceId")
+
+ if err != nil {
+ t.Fatalf("Failed to fetch Cron Jobs: %v", err)
+ }
+ t.Logf("Fetched Events: %v", events)
+ assert.Equal(t, 0, len(events), "Expected to return 0 events")
+ })
+}
diff --git a/api/portainer.go b/api/portainer.go
index f61bb1af2..f63d04ddb 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
+ "net/http"
"time"
"github.com/docker/docker/api/types"
@@ -13,6 +14,7 @@ import (
gittypes "github.com/portainer/portainer/api/git/types"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/pkg/featureflags"
+ httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json"
"golang.org/x/oauth2"
@@ -1531,56 +1533,127 @@ type (
// KubeClient represents a service used to query a Kubernetes environment(endpoint)
KubeClient interface {
- ServerVersion() (*version.Info, error)
+ // Access
+ GetIsKubeAdmin() bool
+ SetIsKubeAdmin(isKubeAdmin bool)
+ GetClientNonAdminNamespaces() []string
+ SetClientNonAdminNamespaces([]string)
+ NamespaceAccessPoliciesDeleteNamespace(ns string) error
+ UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
+ GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
+ GetNonAdminNamespaces(userID int, teamIDs []int, isRestrictDefaultNamespace bool) ([]string, error)
- SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error
- IsRBACEnabled() (bool, error)
- GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error)
- GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error)
- DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error
- GetServiceAccountBearerToken(userID int) (string, error)
- CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error)
+ // Applications
+ GetApplications(namespace, nodeName string) ([]models.K8sApplication, error)
+ GetApplicationsResource(namespace, node string) (models.K8sApplicationResource, error)
+
+ // ClusterRole
+ GetClusterRoles() ([]models.K8sClusterRole, error)
+ DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error
+
+ // ConfigMap
+ GetConfigMap(namespace, configMapName string) (models.K8sConfigMap, error)
+ CombineConfigMapWithApplications(configMap models.K8sConfigMap) (models.K8sConfigMap, error)
+
+ // CronJob
+ GetCronJobs(namespace string) ([]models.K8sCronJob, error)
+ DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error
+
+ // Event
+ GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error)
+
+ // Exec
StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error)
+ // ClusterRoleBinding
+ GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error)
+ DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error
+
+ // Dashboard
+ GetDashboard() (models.K8sDashboard, error)
+
+ // Deployment
HasStackName(namespace string, stackName string) (bool, error)
- NamespaceAccessPoliciesDeleteNamespace(namespace string) error
- CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
- UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
- GetNamespaces() (map[string]K8sNamespaceInfo, error)
- GetNamespace(string) (K8sNamespaceInfo, error)
- DeleteNamespace(namespace string) (*corev1.Namespace, error)
- GetConfigMaps(namespace string) ([]models.K8sConfigMap, error)
- GetSecrets(namespace string) ([]models.K8sSecret, error)
+
+ // Ingress
GetIngressControllers() (models.K8sIngressControllers, error)
- GetApplications(namespace, nodename string) ([]models.K8sApplication, error)
- GetMetrics() (models.K8sMetrics, error)
- GetStorage() ([]KubernetesStorageClassConfig, error)
- CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
- UpdateIngress(namespace string, info models.K8sIngressInfo) error
+ GetIngress(namespace, ingressName string) (models.K8sIngressInfo, error)
GetIngresses(namespace string) ([]models.K8sIngressInfo, error)
+ CreateIngress(namespace string, info models.K8sIngressInfo, owner string) error
DeleteIngresses(reqs models.K8sIngressDeleteRequests) error
- CreateService(namespace string, service models.K8sServiceInfo) error
- UpdateService(namespace string, service models.K8sServiceInfo) error
- GetServices(namespace string) ([]models.K8sServiceInfo, error)
- DeleteServices(reqs models.K8sServiceDeleteRequests) error
+ UpdateIngress(namespace string, info models.K8sIngressInfo) error
+ CombineIngressWithService(ingress models.K8sIngressInfo) (models.K8sIngressInfo, error)
+ CombineIngressesWithServices(ingresses []models.K8sIngressInfo) ([]models.K8sIngressInfo, error)
+
+ // Job
+ GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error)
+ DeleteJobs(payload models.K8sJobDeleteRequests) error
+
+ // Metrics
+ GetMetrics() (models.K8sMetrics, error)
+
+ // Namespace
+ ToggleSystemState(namespaceName string, isSystem bool) error
+ UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
+ GetNamespace(name string) (K8sNamespaceInfo, error)
+ CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error)
+ GetNamespaces() (map[string]K8sNamespaceInfo, error)
+ CombineNamespaceWithResourceQuota(namespace K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError
+ DeleteNamespace(namespaceName string) (*corev1.Namespace, error)
+ CombineNamespacesWithResourceQuotas(namespaces map[string]K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError
+ ConvertNamespaceMapToSlice(namespaces map[string]K8sNamespaceInfo) []K8sNamespaceInfo
+
+ // NodeLimits
GetNodesLimits() (K8sNodesLimits, error)
- GetMaxResourceLimits(name string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error)
- GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
- UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
+ GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error)
+
+ // Pod
+ CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error)
+
+ // RBAC
+ IsRBACEnabled() (bool, error)
+
+ // Registries
DeleteRegistrySecret(registry RegistryID, namespace string) error
CreateRegistrySecret(registry *Registry, namespace string) error
IsRegistrySecret(namespace, secretName string) (bool, error)
- ToggleSystemState(namespace string, isSystem bool) error
- GetClusterRoles() ([]models.K8sClusterRole, error)
- DeleteClusterRoles(models.K8sClusterRoleDeleteRequests) error
- GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error)
- DeleteClusterRoleBindings(models.K8sClusterRoleBindingDeleteRequests) error
-
- GetRoles(namespace string) ([]models.K8sRole, error)
- DeleteRoles(models.K8sRoleDeleteRequests) error
+ // RoleBinding
GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error)
- DeleteRoleBindings(models.K8sRoleBindingDeleteRequests) error
+ DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error
+
+ // Role
+ DeleteRoles(reqs models.K8sRoleDeleteRequests) error
+
+ // Secret
+ GetSecrets(namespace string) ([]models.K8sSecret, error)
+ GetSecret(namespace string, secretName string) (models.K8sSecret, error)
+ CombineSecretWithApplications(secret models.K8sSecret) (models.K8sSecret, error)
+
+ // ServiceAccount
+ GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error)
+ DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error
+ SetupUserServiceAccount(int, []int, bool) error
+ GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error)
+ GetServiceAccountBearerToken(userID int) (string, error)
+
+ // Service
+ GetServices(namespace string) ([]models.K8sServiceInfo, error)
+ CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error)
+ CreateService(namespace string, info models.K8sServiceInfo) error
+ DeleteServices(reqs models.K8sServiceDeleteRequests) error
+ UpdateService(namespace string, info models.K8sServiceInfo) error
+
+ // ServerVersion
+ ServerVersion() (*version.Info, error)
+
+ // Storage
+ GetStorage() ([]KubernetesStorageClassConfig, error)
+
+ // Volumes
+ GetVolumes(namespace string) ([]models.K8sVolumeInfo, error)
+ GetVolume(namespace, volumeName string) (*models.K8sVolumeInfo, error)
+ CombineVolumesWithApplications(volumes *[]models.K8sVolumeInfo) (*[]models.K8sVolumeInfo, error)
}
// KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint)
diff --git a/app/kubernetes/views/configurations/configmap/edit/configMap.html b/app/kubernetes/views/configurations/configmap/edit/configMap.html
index 89e1c38c5..70a2d4f1a 100644
--- a/app/kubernetes/views/configurations/configmap/edit/configMap.html
+++ b/app/kubernetes/views/configurations/configmap/edit/configMap.html
@@ -58,7 +58,7 @@
diff --git a/app/kubernetes/views/configurations/secret/edit/secret.html b/app/kubernetes/views/configurations/secret/edit/secret.html
index 0309d356c..2e939c87e 100644
--- a/app/kubernetes/views/configurations/secret/edit/secret.html
+++ b/app/kubernetes/views/configurations/secret/edit/secret.html
@@ -65,7 +65,7 @@
diff --git a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.test.tsx b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.test.tsx
new file mode 100644
index 000000000..c5dc49c16
--- /dev/null
+++ b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.test.tsx
@@ -0,0 +1,83 @@
+import { render, screen } from '@testing-library/react';
+
+import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
+import { withTestRouter } from '@/react/test-utils/withRouter';
+import { UserViewModel } from '@/portainer/models/user';
+import { withUserProvider } from '@/react/test-utils/withUserProvider';
+import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
+
+import { TableState } from '@@/datatables/useTableState';
+
+import { Event } from '../../queries/types';
+
+import { EventsDatatable } from './EventsDatatable';
+
+// Mock the necessary hooks and dependencies
+const mockTableState: TableState = {
+ sortBy: { id: 'Date', desc: true },
+ pageSize: 10,
+ search: '',
+ autoRefreshRate: 0,
+ showSystemResources: false,
+ setSortBy: vi.fn(),
+ setPageSize: vi.fn(),
+ setSearch: vi.fn(),
+ setAutoRefreshRate: vi.fn(),
+ setShowSystemResources: vi.fn(),
+};
+
+vi.mock('../../datatables/default-kube-datatable-store', () => ({
+ useKubeStore: () => mockTableState,
+}));
+
+function renderComponent() {
+ const user = new UserViewModel({ Username: 'user' });
+
+ const events: Event[] = [
+ {
+ type: 'Warning',
+ name: 'name',
+ message: 'not sure if this what you want to do',
+ namespace: 'default',
+ reason: 'unknown',
+ count: 1,
+ eventTime: new Date('2025-01-02T15:04:05Z'),
+ uid: '4500fc9c-0cc8-4695-b4c4-989ac021d1d6',
+ involvedObject: {
+ kind: 'configMap',
+ uid: '35',
+ name: 'name',
+ namespace: 'default',
+ },
+ },
+ ];
+
+ const Wrapped = withTestQueryProvider(
+ withUserProvider(
+ withTestRouter(() => (
+
+ )),
+ user
+ )
+ );
+ return { ...render( ), events };
+}
+
+describe('EventsDatatable', () => {
+ it('should display events when data is loaded', async () => {
+ const { events } = renderComponent();
+ const event = events[0];
+
+ expect(screen.getByText(event.message || '')).toBeInTheDocument();
+ expect(screen.getAllByText(event.type || '')).toHaveLength(2);
+ expect(screen.getAllByText(event.involvedObject.kind || '')).toHaveLength(
+ 2
+ );
+ });
+});
diff --git a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx
index ffb92e7bd..fd063535a 100644
--- a/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx
+++ b/app/react/kubernetes/components/EventsDatatable/EventsDatatable.tsx
@@ -1,7 +1,7 @@
-import { Event } from 'kubernetes-types/core/v1';
import { History } from 'lucide-react';
import { ReactNode } from 'react';
+import { Event } from '@/react/kubernetes/queries/types';
import { IndexOptional } from '@/react/kubernetes/configs/types';
import { TableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
@@ -38,7 +38,7 @@ export function EventsDatatable({
isLoading={isLoading}
title={title}
titleIcon={titleIcon}
- getRowId={(row) => row.metadata?.uid || ''}
+ getRowId={(row) => row.uid || ''}
disableSelect
renderTableSettings={() => (
diff --git a/app/react/kubernetes/components/EventsDatatable/ResourceEventsDatatable.tsx b/app/react/kubernetes/components/EventsDatatable/ResourceEventsDatatable.tsx
index f763043c9..bf04d00d9 100644
--- a/app/react/kubernetes/components/EventsDatatable/ResourceEventsDatatable.tsx
+++ b/app/react/kubernetes/components/EventsDatatable/ResourceEventsDatatable.tsx
@@ -29,9 +29,7 @@ export function ResourceEventsDatatable({
params: { endpointId },
} = useCurrentStateAndParams();
- const params = resourceId
- ? { fieldSelector: `involvedObject.uid=${resourceId}` }
- : {};
+ const params = resourceId ? { resourceId: `${resourceId}` } : {};
const resourceEventsQuery = useEvents(endpointId, {
namespace,
params,
diff --git a/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx b/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx
index 0d381d682..d2f24e3ab 100644
--- a/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx
+++ b/app/react/kubernetes/components/EventsDatatable/columns/eventType.tsx
@@ -1,5 +1,6 @@
import { Row } from '@tanstack/react-table';
-import { Event } from 'kubernetes-types/core/v1';
+
+import { Event } from '@/react/kubernetes/queries/types';
import { Badge, BadgeType } from '@@/Badge';
import { filterHOC } from '@@/datatables/Filter';
diff --git a/app/react/kubernetes/components/EventsDatatable/columns/helper.ts b/app/react/kubernetes/components/EventsDatatable/columns/helper.ts
index 23a453238..dbb2a57d5 100644
--- a/app/react/kubernetes/components/EventsDatatable/columns/helper.ts
+++ b/app/react/kubernetes/components/EventsDatatable/columns/helper.ts
@@ -1,4 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
-import { Event } from 'kubernetes-types/core/v1';
+
+import { Event } from '@/react/kubernetes/queries/types';
export const columnHelper = createColumnHelper();
diff --git a/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx b/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx
index 641662291..efc69fe4e 100644
--- a/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx
+++ b/app/react/kubernetes/components/EventsDatatable/columns/kind.tsx
@@ -1,5 +1,6 @@
import { Row } from '@tanstack/react-table';
-import { Event } from 'kubernetes-types/core/v1';
+
+import { Event } from '@/react/kubernetes/queries/types';
import { filterHOC } from '@@/datatables/Filter';
diff --git a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
index 89f522d3a..9cad1d046 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/HelmApplicationView.test.tsx
@@ -184,15 +184,8 @@ describe(
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory)
),
- http.get(
- '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
- () =>
- HttpResponse.json({
- kind: 'EventList',
- apiVersion: 'v1',
- metadata: { resourceVersion: '12345' },
- items: [],
- })
+ http.get('/api/kubernetes/3/namespaces/default/events', () =>
+ HttpResponse.json([])
)
);
@@ -236,15 +229,8 @@ describe(
HttpResponse.error()
),
// Add mock for events endpoint
- http.get(
- '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
- () =>
- HttpResponse.json({
- kind: 'EventList',
- apiVersion: 'v1',
- metadata: { resourceVersion: '12345' },
- items: [],
- })
+ http.get('/api/kubernetes/3/namespaces/default/events', () =>
+ HttpResponse.json([])
)
);
@@ -274,15 +260,8 @@ describe(
http.get('/api/endpoints/3/kubernetes/helm/test-release/history', () =>
HttpResponse.json(helmReleaseHistory)
),
- http.get(
- '/api/endpoints/3/kubernetes/api/v1/namespaces/default/events',
- () =>
- HttpResponse.json({
- kind: 'EventList',
- apiVersion: 'v1',
- metadata: { resourceVersion: '12345' },
- items: [],
- })
+ http.get('/api/kubernetes/3/namespaces/default/events', () =>
+ HttpResponse.json([])
)
);
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx
index 08c48488a..67ff7d10b 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.test.tsx
@@ -1,7 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import { HttpResponse } from 'msw';
-import { Event, EventList } from 'kubernetes-types/core/v1';
+import { Event } from '@/react/kubernetes/queries/types';
import { server, http } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withTestRouter } from '@/react/test-utils/withRouter';
@@ -56,136 +56,84 @@ const testResources: GenericResource[] = [
},
];
-const mockEventsResponse: EventList = {
- kind: 'EventList',
- apiVersion: 'v1',
- metadata: {
- resourceVersion: '12345',
+const mockEventsResponse: Event[] = [
+ {
+ name: 'test-deployment-123456',
+ namespace: 'default',
+ reason: 'CreatedLoadBalancer',
+ eventTime: new Date('2023-01-01T00:00:00Z'),
+ uid: 'event-uid-1',
+ involvedObject: {
+ kind: 'Deployment',
+ name: 'test-deployment',
+ uid: 'test-deployment-uid',
+ namespace: 'default',
+ },
+ message: 'Scaled up replica set test-deployment-abc123 to 1',
+ firstTimestamp: new Date('2023-01-01T00:00:00Z'),
+ lastTimestamp: new Date('2023-01-01T00:00:00Z'),
+ count: 1,
+ type: 'Normal',
},
- items: [
- {
- metadata: {
- name: 'test-deployment-123456',
- namespace: 'default',
- uid: 'event-uid-1',
- resourceVersion: '1000',
- creationTimestamp: '2023-01-01T00:00:00Z',
- },
- involvedObject: {
- kind: 'Deployment',
- namespace: 'default',
- name: 'test-deployment',
- uid: 'test-deployment-uid',
- apiVersion: 'apps/v1',
- resourceVersion: '2000',
- },
- reason: 'ScalingReplicaSet',
- message: 'Scaled up replica set test-deployment-abc123 to 1',
- source: {
- component: 'deployment-controller',
- },
- firstTimestamp: '2023-01-01T00:00:00Z',
- lastTimestamp: '2023-01-01T00:00:00Z',
- count: 1,
- type: 'Normal',
- reportingComponent: 'deployment-controller',
- reportingInstance: '',
+ {
+ name: 'test-service-123456',
+ namespace: 'default',
+ uid: 'event-uid-2',
+ eventTime: new Date('2023-01-01T00:00:00Z'),
+ involvedObject: {
+ kind: 'Service',
+ namespace: 'default',
+ name: 'test-service',
+ uid: 'test-service-uid',
},
- {
- metadata: {
- name: 'test-service-123456',
- namespace: 'default',
- uid: 'event-uid-2',
- resourceVersion: '1001',
- creationTimestamp: '2023-01-01T00:00:00Z',
- },
- involvedObject: {
- kind: 'Service',
- namespace: 'default',
- name: 'test-service',
- uid: 'test-service-uid',
- apiVersion: 'v1',
- resourceVersion: '2001',
- },
- reason: 'CreatedLoadBalancer',
- message: 'Created load balancer',
- source: {
- component: 'service-controller',
- },
- firstTimestamp: '2023-01-01T00:00:00Z',
- lastTimestamp: '2023-01-01T00:00:00Z',
- count: 1,
- type: 'Normal',
- reportingComponent: 'service-controller',
- reportingInstance: '',
- },
- ],
-};
+ reason: 'CreatedLoadBalancer',
+ message: 'Created load balancer',
+ firstTimestamp: new Date('2023-01-01T00:00:00Z'),
+ lastTimestamp: new Date('2023-01-01T00:00:00Z'),
+ count: 1,
+ type: 'Normal',
+ },
+];
-const mixedEventsResponse: EventList = {
- kind: 'EventList',
- apiVersion: 'v1',
- metadata: {
- resourceVersion: '12345',
+const mixedEventsResponse: Event[] = [
+ {
+ name: 'test-deployment-123456',
+ namespace: 'default',
+ uid: 'event-uid-1',
+ eventTime: new Date('2023-01-01T00:00:00Z'),
+ involvedObject: {
+ kind: 'Deployment',
+ namespace: 'default',
+ name: 'test-deployment',
+ uid: 'test-deployment-uid', // This matches a resource UID
+ },
+ reason: 'ScalingReplicaSet',
+ message: 'Scaled up replica set test-deployment-abc123 to 1',
+
+ firstTimestamp: new Date('2023-01-01T00:00:00Z'),
+ lastTimestamp: new Date('2023-01-01T00:00:00Z'),
+ count: 1,
+ type: 'Normal',
},
- items: [
- {
- metadata: {
- name: 'test-deployment-123456',
- namespace: 'default',
- uid: 'event-uid-1',
- resourceVersion: '1000',
- creationTimestamp: '2023-01-01T00:00:00Z',
- },
- involvedObject: {
- kind: 'Deployment',
- namespace: 'default',
- name: 'test-deployment',
- uid: 'test-deployment-uid', // This matches a resource UID
- apiVersion: 'apps/v1',
- resourceVersion: '2000',
- },
- reason: 'ScalingReplicaSet',
- message: 'Scaled up replica set test-deployment-abc123 to 1',
- source: {
- component: 'deployment-controller',
- },
- firstTimestamp: '2023-01-01T00:00:00Z',
- lastTimestamp: '2023-01-01T00:00:00Z',
- count: 1,
- type: 'Normal',
- reportingComponent: 'deployment-controller',
- reportingInstance: '',
+ {
+ name: 'unrelated-pod-123456',
+ namespace: 'default',
+ uid: 'event-uid-3',
+ eventTime: new Date('2023-01-01T00:00:00Z'),
+ involvedObject: {
+ kind: 'Pod',
+ namespace: 'default',
+ name: 'unrelated-pod',
+ uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs
},
- {
- metadata: {
- name: 'unrelated-pod-123456',
- namespace: 'default',
- uid: 'event-uid-3',
- resourceVersion: '1002',
- creationTimestamp: '2023-01-01T00:00:00Z',
- },
- involvedObject: {
- kind: 'Pod',
- namespace: 'default',
- name: 'unrelated-pod',
- uid: 'unrelated-pod-uid', // This does NOT match any resource UIDs
- apiVersion: 'v1',
- resourceVersion: '2002',
- },
- reason: 'Scheduled',
- message: 'Successfully assigned unrelated-pod to node',
- source: {
- component: 'default-scheduler',
- },
- firstTimestamp: '2023-01-01T00:00:00Z',
- lastTimestamp: '2023-01-01T00:00:00Z',
- count: 1,
- reportingComponent: 'scheduler',
- reportingInstance: '',
- },
- ],
-};
+ reason: 'Scheduled',
+ message: 'Successfully assigned unrelated-pod to node',
+ type: 'Normal',
+ firstTimestamp: new Date('2023-01-01T00:00:00Z'),
+ lastTimestamp: new Date('2023-01-01T00:00:00Z'),
+ count: 1,
+ },
+];
function renderComponent() {
const user = new UserViewModel({ Username: 'user' });
@@ -229,7 +177,7 @@ describe('HelmEventsDatatable', () => {
it('should correctly filter related events using the filterRelatedEvents function', () => {
const filteredEvents = filterRelatedEvents(
- mixedEventsResponse.items as Event[],
+ mixedEventsResponse as Event[],
testResources
);
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx
index ced859f54..9dd161f5b 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ReleaseDetails/HelmEventsDatatable.tsx
@@ -1,6 +1,6 @@
import { compact } from 'lodash';
-import { Event } from 'kubernetes-types/core/v1';
+import { Event } from '@/react/kubernetes/queries/types';
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
import { EventsDatatable } from '@/react/kubernetes/components/EventsDatatable';
import { useEvents } from '@/react/kubernetes/queries/useEvents';
diff --git a/app/react/kubernetes/queries/types.ts b/app/react/kubernetes/queries/types.ts
new file mode 100644
index 000000000..4c8269c28
--- /dev/null
+++ b/app/react/kubernetes/queries/types.ts
@@ -0,0 +1,19 @@
+export type Event = {
+ type: string;
+ name: string;
+ reason: string;
+ message: string;
+ namespace: string;
+ eventTime: Date;
+ kind?: string;
+ count: number;
+ lastTimestamp?: Date;
+ firstTimestamp?: Date;
+ uid: string;
+ involvedObject: {
+ uid: string;
+ kind?: string;
+ name: string;
+ namespace: string;
+ };
+};
diff --git a/app/react/kubernetes/queries/useEvents.ts b/app/react/kubernetes/queries/useEvents.ts
index f25c255db..47ea5c2c5 100644
--- a/app/react/kubernetes/queries/useEvents.ts
+++ b/app/react/kubernetes/queries/useEvents.ts
@@ -1,6 +1,6 @@
-import { EventList, Event } from 'kubernetes-types/core/v1';
import { useQuery } from '@tanstack/react-query';
+import { Event } from '@/react/kubernetes/queries/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
@@ -13,10 +13,7 @@ type RequestOptions = {
/** if undefined, events are fetched at the cluster scope */
namespace?: string;
params?: {
- /** https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors */
- labelSelector?: string;
- /** https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors */
- fieldSelector?: string;
+ resourceId?: string;
};
};
@@ -44,13 +41,13 @@ async function getEvents(
): Promise {
const { namespace, params } = options ?? {};
try {
- const { data } = await axios.get(
+ const { data } = await axios.get(
buildUrl(environmentId, namespace),
{
params,
}
);
- return data.items;
+ return data;
} catch (e) {
throw parseKubernetesAxiosError(e, 'Unable to retrieve events');
}
@@ -96,6 +93,6 @@ export function useEventWarningsCount(
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
return namespace
- ? `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/events`
- : `/endpoints/${environmentId}/kubernetes/api/v1/events`;
+ ? `/kubernetes/${environmentId}/namespaces/${namespace}/events`
+ : `/kubernetes/${environmentId}/events`;
}
From 731afbee4697ef4eb55c075b6b851f5ab9414fa6 Mon Sep 17 00:00:00 2001
From: Cara Ryan
Date: Tue, 27 May 2025 15:20:28 +1200
Subject: [PATCH 133/212] feat(helm): filter on chart versions at API level
[R8S-324] (#754)
---
api/http/handler/helm/helm_repo_search.go | 11 ++-
.../ChartActions/UpgradeButton.test.tsx | 10 ++
.../ChartActions/UpgradeButton.tsx | 95 +++++++++++++------
.../queries/useHelmRepositories.ts | 16 ++--
pkg/libhelm/options/search_repo_options.go | 6 +-
pkg/libhelm/sdk/search_repo.go | 77 +++++++++++----
6 files changed, 159 insertions(+), 56 deletions(-)
diff --git a/api/http/handler/helm/helm_repo_search.go b/api/http/handler/helm/helm_repo_search.go
index aab9c523d..c29423fa9 100644
--- a/api/http/handler/helm/helm_repo_search.go
+++ b/api/http/handler/helm/helm_repo_search.go
@@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/pkg/libhelm/options"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
+ "github.com/portainer/portainer/pkg/libhttp/request"
"github.com/pkg/errors"
)
@@ -17,6 +18,8 @@ import (
// @description **Access policy**: authenticated
// @tags helm
// @param repo query string true "Helm repository URL"
+// @param chart query string false "Helm chart name"
+// @param useCache query string false "If true will use cache to search"
// @security ApiKeyAuth
// @security jwt
// @produce json
@@ -32,13 +35,19 @@ func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *
return httperror.BadRequest("Bad request", errors.New("missing `repo` query parameter"))
}
+ chart, _ := request.RetrieveQueryParameter(r, "chart", false)
+ // If true will useCache to search, will always add to cache after
+ useCache, _ := request.RetrieveBooleanQueryParameter(r, "useCache", false)
+
_, err := url.ParseRequestURI(repo)
if err != nil {
return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo)))
}
searchOpts := options.SearchRepoOptions{
- Repo: repo,
+ Repo: repo,
+ Chart: chart,
+ UseCache: useCache,
}
result, err := handler.helmPackageManager.SearchRepo(searchOpts)
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx
index 4bea0bf47..190fef7b1 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.test.tsx
@@ -33,6 +33,8 @@ vi.mock('../queries/useHelmRepositories', () => ({
],
isInitialLoading: false,
isError: false,
+ isFetching: false,
+ refetch: vi.fn(() => Promise.resolve([])),
})),
useHelmRepositories: vi.fn(() => ({
data: ['repo1', 'repo2'],
@@ -81,6 +83,8 @@ describe('UpgradeButton', () => {
data,
isInitialLoading: false,
isError: false,
+ isFetching: false,
+ refetch: vi.fn(() => Promise.resolve([])),
});
renderButton();
@@ -94,6 +98,8 @@ describe('UpgradeButton', () => {
data: [],
isInitialLoading: true,
isError: false,
+ isFetching: true,
+ refetch: vi.fn(() => Promise.resolve([])),
});
renderButton();
@@ -109,6 +115,8 @@ describe('UpgradeButton', () => {
data,
isInitialLoading: false,
isError: false,
+ isFetching: false,
+ refetch: vi.fn(() => Promise.resolve([])),
});
renderButton();
@@ -139,6 +147,8 @@ describe('UpgradeButton', () => {
],
isInitialLoading: false,
isError: false,
+ isFetching: false,
+ refetch: vi.fn(() => Promise.resolve([])),
});
renderButton({ release: mockRelease });
diff --git a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx
index 4c385c388..2c32fb69d 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx
+++ b/app/react/kubernetes/helm/HelmApplicationView/ChartActions/UpgradeButton.tsx
@@ -1,5 +1,6 @@
import { ArrowUp } from 'lucide-react';
import { useRouter } from '@uirouter/react';
+import { useState } from 'react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
@@ -41,26 +42,28 @@ export function UpgradeButton({
const updateHelmReleaseMutation = useUpdateHelmReleaseMutation(environmentId);
const repositoriesQuery = useHelmRepositories();
+ const [useCache, setUseCache] = useState(true);
const helmRepoVersionsQuery = useHelmRepoVersions(
release?.chart.metadata?.name || '',
60 * 60 * 1000, // 1 hour
- repositoriesQuery.data
+ repositoriesQuery.data,
+ useCache
);
const versions = helmRepoVersionsQuery.data;
// Combined loading state
- const isInitialLoading =
- repositoriesQuery.isInitialLoading ||
- helmRepoVersionsQuery.isInitialLoading;
+ const isLoading =
+ repositoriesQuery.isInitialLoading || helmRepoVersionsQuery.isFetching;
const isError = repositoriesQuery.isError || helmRepoVersionsQuery.isError;
const latestVersion = useHelmRelease(environmentId, releaseName, namespace, {
select: (data) => data.chart.metadata?.version,
});
const latestVersionAvailable = versions[0]?.Version ?? '';
- const isNewVersionAvailable =
+ const isNewVersionAvailable = Boolean(
latestVersion?.data &&
- semverCompare(latestVersionAvailable, latestVersion?.data) === 1;
+ semverCompare(latestVersionAvailable, latestVersion?.data) === 1
+ );
const editableHelmRelease: UpdateHelmReleasePayload = {
name: releaseName,
@@ -70,6 +73,14 @@ export function UpgradeButton({
version: release?.chart.metadata?.version,
};
+ function handleRefreshVersions() {
+ if (!useCache) {
+ helmRepoVersionsQuery.refetch();
+ } else {
+ setUseCache(false);
+ }
+ }
+
return (
openUpgradeForm(versions, release)}
disabled={
versions.length === 0 ||
- isInitialLoading ||
+ isLoading ||
isError ||
release?.info?.status?.startsWith('pending')
}
@@ -89,7 +100,7 @@ export function UpgradeButton({
>
Upgrade
- {versions.length === 0 && isInitialLoading && (
+ {isLoading && (
)}
- {versions.length === 0 && !isInitialLoading && !isError && (
+ {!isLoading && !isError && (
- No versions available
-
- Portainer is unable to find any versions for this chart in the
- repositories saved. Try adding a new repository which contains
- the chart in the{' '}
-
- Helm repositories settings
-
-
- }
- />
-
- )}
- {isNewVersionAvailable && (
-
- New version available ({latestVersionAvailable})
+ {getStatusMessage(
+ versions.length === 0,
+ latestVersionAvailable,
+ isNewVersionAvailable
+ )}
+ {versions.length === 0 && (
+
+ Portainer is unable to find any versions for this chart in the
+ repositories saved. Try adding a new repository which contains
+ the chart in the{' '}
+
+ Helm repositories settings
+
+
+ }
+ />
+ )}
+
+ Refresh versions
+
)}
@@ -164,4 +183,18 @@ export function UpgradeButton({
},
});
}
+
+ function getStatusMessage(
+ hasNoAvailableVersions: boolean,
+ latestVersionAvailable: string,
+ isNewVersionAvailable: boolean
+ ): string {
+ if (hasNoAvailableVersions) {
+ return 'No versions available ';
+ }
+ if (isNewVersionAvailable) {
+ return `New version available (${latestVersionAvailable}) `;
+ }
+ return '';
+ }
}
diff --git a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts
index 733b79dea..bf11eadb1 100644
--- a/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts
+++ b/app/react/kubernetes/helm/HelmApplicationView/queries/useHelmRepositories.ts
@@ -45,20 +45,21 @@ export function useHelmRepositories() {
export function useHelmRepoVersions(
chart: string,
staleTime: number,
- repositories: string[] = []
+ repositories: string[] = [],
+ useCache: boolean = true
) {
// Fetch versions from each repository in parallel as separate queries
const versionQueries = useQueries({
queries: useMemo(
() =>
repositories.map((repo) => ({
- queryKey: ['helm', 'repositories', chart, repo],
- queryFn: () => getSearchHelmRepo(repo, chart),
+ queryKey: ['helm', 'repositories', chart, repo, useCache],
+ queryFn: () => getSearchHelmRepo(repo, chart, useCache),
enabled: !!chart && repositories.length > 0,
staleTime,
...withGlobalError(`Unable to retrieve versions from ${repo}`),
})),
- [repositories, chart, staleTime]
+ [repositories, chart, staleTime, useCache]
),
});
@@ -72,6 +73,8 @@ export function useHelmRepoVersions(
data: allVersions,
isInitialLoading: versionQueries.some((q) => q.isLoading),
isError: versionQueries.some((q) => q.isError),
+ isFetching: versionQueries.some((q) => q.isFetching),
+ refetch: () => Promise.all(versionQueries.map((q) => q.refetch())),
};
}
@@ -80,11 +83,12 @@ export function useHelmRepoVersions(
*/
async function getSearchHelmRepo(
repo: string,
- chart: string
+ chart: string,
+ useCache: boolean = true
): Promise {
try {
const { data } = await axios.get(`templates/helm`, {
- params: { repo, chart },
+ params: { repo, chart, useCache },
});
const versions = data.entries[chart];
return (
diff --git a/pkg/libhelm/options/search_repo_options.go b/pkg/libhelm/options/search_repo_options.go
index 73acc5709..0b35c0bbd 100644
--- a/pkg/libhelm/options/search_repo_options.go
+++ b/pkg/libhelm/options/search_repo_options.go
@@ -3,6 +3,8 @@ package options
import "net/http"
type SearchRepoOptions struct {
- Repo string `example:"https://charts.gitlab.io/"`
- Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"`
+ Repo string `example:"https://charts.gitlab.io/"`
+ Client *http.Client `example:"&http.Client{Timeout: time.Second * 10}"`
+ Chart string `example:"my-chart"`
+ UseCache bool `example:"false"`
}
diff --git a/pkg/libhelm/sdk/search_repo.go b/pkg/libhelm/sdk/search_repo.go
index 90dd98529..63015330c 100644
--- a/pkg/libhelm/sdk/search_repo.go
+++ b/pkg/libhelm/sdk/search_repo.go
@@ -4,6 +4,8 @@ import (
"net/url"
"os"
"path/filepath"
+ "sync"
+ "time"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options"
@@ -25,6 +27,17 @@ type RepoIndex struct {
Generated string `json:"generated"`
}
+type RepoIndexCache struct {
+ Index *repo.IndexFile
+ Timestamp time.Time
+}
+
+var (
+ indexCache = make(map[string]RepoIndexCache)
+ cacheMutex sync.RWMutex
+ cacheDuration = 60 * time.Minute
+)
+
// SearchRepo downloads the `index.yaml` file for specified repo, parses it and returns JSON to caller.
func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoOptions) ([]byte, error) {
// Validate input options
@@ -53,6 +66,18 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
return nil, err
}
+ // Check cache first
+ if searchRepoOpts.UseCache {
+ cacheMutex.RLock()
+ if cached, exists := indexCache[repoURL.String()]; exists {
+ if time.Since(cached.Timestamp) < cacheDuration {
+ cacheMutex.RUnlock()
+ return convertAndMarshalIndex(cached.Index, searchRepoOpts.Chart)
+ }
+ }
+ cacheMutex.RUnlock()
+ }
+
// Set up Helm CLI environment
repoSettings := cli.New()
@@ -92,23 +117,21 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
return nil, err
}
- // Convert the index file to our response format
- result, err := convertIndexToResponse(indexFile)
- if err != nil {
- log.Error().
- Str("context", "HelmClient").
- Err(err).
- Msg("Failed to convert index to response format")
- return nil, errors.Wrap(err, "failed to convert index to response format")
+ // Update cache and remove old entries
+ cacheMutex.Lock()
+ indexCache[searchRepoOpts.Repo] = RepoIndexCache{
+ Index: indexFile,
+ Timestamp: time.Now(),
+ }
+ for key, index := range indexCache {
+ if time.Since(index.Timestamp) > cacheDuration {
+ delete(indexCache, key)
+ }
}
- log.Debug().
- Str("context", "HelmClient").
- Str("repo", searchRepoOpts.Repo).
- Int("entries_count", len(indexFile.Entries)).
- Msg("Successfully searched repository")
+ cacheMutex.Unlock()
- return json.Marshal(result)
+ return convertAndMarshalIndex(indexFile, searchRepoOpts.Chart)
}
// validateSearchRepoOptions validates the required search repository options.
@@ -216,7 +239,7 @@ func loadIndexFile(indexPath string) (*repo.IndexFile, error) {
}
// convertIndexToResponse converts the Helm index file to our response format.
-func convertIndexToResponse(indexFile *repo.IndexFile) (RepoIndex, error) {
+func convertIndexToResponse(indexFile *repo.IndexFile, chartName string) (RepoIndex, error) {
result := RepoIndex{
APIVersion: indexFile.APIVersion,
Entries: make(map[string][]ChartInfo),
@@ -225,7 +248,9 @@ func convertIndexToResponse(indexFile *repo.IndexFile) (RepoIndex, error) {
// Convert Helm SDK types to our response types
for name, charts := range indexFile.Entries {
- result.Entries[name] = convertChartsToChartInfo(charts)
+ if chartName == "" || name == chartName {
+ result.Entries[name] = convertChartsToChartInfo(charts)
+ }
}
return result, nil
@@ -349,3 +374,23 @@ func ensureHelmDirectoriesExist(settings *cli.EnvSettings) error {
return nil
}
+
+func convertAndMarshalIndex(indexFile *repo.IndexFile, chartName string) ([]byte, error) {
+ // Convert the index file to our response format
+ result, err := convertIndexToResponse(indexFile, chartName)
+ if err != nil {
+ log.Error().
+ Str("context", "HelmClient").
+ Err(err).
+ Msg("Failed to convert index to response format")
+ return nil, errors.Wrap(err, "failed to convert index to response format")
+ }
+
+ log.Debug().
+ Str("context", "HelmClient").
+ Str("repo", chartName).
+ Int("entries_count", len(indexFile.Entries)).
+ Msg("Successfully searched repository")
+
+ return json.Marshal(result)
+}
From b767dcb27ed253b423facd2e04ef971985950fd3 Mon Sep 17 00:00:00 2001
From: Devon Steenberg
Date: Fri, 30 May 2025 11:49:23 +1200
Subject: [PATCH 134/212] fix(proxy): whitelist headers for proxy to forward
[BE-11819] (#665)
---
api/http/proxy/factory/reverse_proxy.go | 23 ++++-
api/http/proxy/factory/reverse_proxy_test.go | 90 ++++++++++++++++++--
2 files changed, 102 insertions(+), 11 deletions(-)
diff --git a/api/http/proxy/factory/reverse_proxy.go b/api/http/proxy/factory/reverse_proxy.go
index d583d75fe..c40e6c485 100644
--- a/api/http/proxy/factory/reverse_proxy.go
+++ b/api/http/proxy/factory/reverse_proxy.go
@@ -7,6 +7,21 @@ import (
"strings"
)
+// Note that we discard any non-canonical headers by design
+var allowedHeaders = map[string]struct{}{
+ "Accept": {},
+ "Accept-Encoding": {},
+ "Accept-Language": {},
+ "Cache-Control": {},
+ "Content-Length": {},
+ "Content-Type": {},
+ "Private-Token": {},
+ "User-Agent": {},
+ "X-Portaineragent-Target": {},
+ "X-Portainer-Volumename": {},
+ "X-Registry-Auth": {},
+}
+
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
@@ -15,7 +30,6 @@ func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseP
}
func createDirector(target *url.URL) func(*http.Request) {
- sensitiveHeaders := []string{"Cookie", "X-Csrf-Token"}
targetQuery := target.RawQuery
return func(req *http.Request) {
req.URL.Scheme = target.Scheme
@@ -32,8 +46,11 @@ func createDirector(target *url.URL) func(*http.Request) {
req.Header.Set("User-Agent", "")
}
- for _, header := range sensitiveHeaders {
- delete(req.Header, header)
+ for k := range req.Header {
+ if _, ok := allowedHeaders[k]; !ok {
+ // We use delete here instead of req.Header.Del because we want to delete non canonical headers.
+ delete(req.Header, k)
+ }
}
}
}
diff --git a/api/http/proxy/factory/reverse_proxy_test.go b/api/http/proxy/factory/reverse_proxy_test.go
index 1a3d88ba0..6f23d75ec 100644
--- a/api/http/proxy/factory/reverse_proxy_test.go
+++ b/api/http/proxy/factory/reverse_proxy_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
+ portainer "github.com/portainer/portainer/api"
)
func Test_createDirector(t *testing.T) {
@@ -23,12 +24,14 @@ func Test_createDirector(t *testing.T) {
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
+ true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
+ true,
),
},
{
@@ -39,12 +42,14 @@ func Test_createDirector(t *testing.T) {
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
+ true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
+ true,
),
},
{
@@ -55,18 +60,83 @@ func Test_createDirector(t *testing.T) {
"GET",
"https://agent-portainer.io/test?c=7",
map[string]string{
- "Accept-Encoding": "gzip",
- "Accept": "application/json",
- "User-Agent": "something",
- "Cookie": "junk",
- "X-Csrf-Token": "junk",
+ "Authorization": "secret",
+ "Proxy-Authorization": "secret",
+ "Cookie": "secret",
+ "X-Csrf-Token": "secret",
+ "X-Api-Key": "secret",
+ "Accept": "application/json",
+ "Accept-Encoding": "gzip",
+ "Accept-Language": "en-GB",
+ "Cache-Control": "None",
+ "Content-Length": "100",
+ "Content-Type": "application/json",
+ "Private-Token": "test-private-token",
+ "User-Agent": "test-user-agent",
+ "X-Portaineragent-Target": "test-agent-1",
+ "X-Portainer-Volumename": "test-volume-1",
+ "X-Registry-Auth": "test-registry-auth",
},
+ true,
),
expectedReq: createRequest(
t,
"GET",
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
- map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
+ map[string]string{
+ "Accept": "application/json",
+ "Accept-Encoding": "gzip",
+ "Accept-Language": "en-GB",
+ "Cache-Control": "None",
+ "Content-Length": "100",
+ "Content-Type": "application/json",
+ "Private-Token": "test-private-token",
+ "User-Agent": "test-user-agent",
+ "X-Portaineragent-Target": "test-agent-1",
+ "X-Portainer-Volumename": "test-volume-1",
+ "X-Registry-Auth": "test-registry-auth",
+ },
+ true,
+ ),
+ },
+ {
+ name: "Non canonical Headers",
+ target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
+ req: createRequest(
+ t,
+ "GET",
+ "https://agent-portainer.io/test?c=7",
+ map[string]string{
+ "Accept": "application/json",
+ "Accept-Encoding": "gzip",
+ "Accept-Language": "en-GB",
+ "Cache-Control": "None",
+ "Content-Length": "100",
+ "Content-Type": "application/json",
+ "Private-Token": "test-private-token",
+ "User-Agent": "test-user-agent",
+ portainer.PortainerAgentTargetHeader: "test-agent-1",
+ "X-Portainer-VolumeName": "test-volume-1",
+ "X-Registry-Auth": "test-registry-auth",
+ },
+ false,
+ ),
+ expectedReq: createRequest(
+ t,
+ "GET",
+ "https://portainer.io/api/docker/test?a=5&b=6&c=7",
+ map[string]string{
+ "Accept": "application/json",
+ "Accept-Encoding": "gzip",
+ "Accept-Language": "en-GB",
+ "Cache-Control": "None",
+ "Content-Length": "100",
+ "Content-Type": "application/json",
+ "Private-Token": "test-private-token",
+ "User-Agent": "test-user-agent",
+ "X-Registry-Auth": "test-registry-auth",
+ },
+ true,
),
},
}
@@ -92,13 +162,17 @@ func createURL(t *testing.T, urlString string) *url.URL {
return parsedURL
}
-func createRequest(t *testing.T, method, url string, headers map[string]string) *http.Request {
+func createRequest(t *testing.T, method, url string, headers map[string]string, canonicalHeaders bool) *http.Request {
req, err := http.NewRequest(method, url, nil)
if err != nil {
t.Fatalf("Failed to create http request: %s", err)
} else {
for k, v := range headers {
- req.Header.Add(k, v)
+ if canonicalHeaders {
+ req.Header.Add(k, v)
+ } else {
+ req.Header[k] = []string{v}
+ }
}
}
From 24ff7a79112712e80ca2dedebc55f46f64510e16 Mon Sep 17 00:00:00 2001
From: LP B
Date: Fri, 30 May 2025 09:12:27 +0200
Subject: [PATCH 135/212] chore(deps): upgrade docker/cli to v28.2.1 |
docker/docker to v28.2.1 | docker/compose to v2.36.2 (#758)
---
.../test_data/output_24_to_latest.json | 5 -
api/http/handler/docker/dashboard.go | 5 +-
api/http/proxy/factory/docker/networks.go | 4 +-
api/portainer.go | 3 +-
go.mod | 183 ++++----
go.sum | 417 +++++++++---------
pkg/snapshot/docker.go | 3 +-
7 files changed, 324 insertions(+), 296 deletions(-)
diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json
index b0078c326..40971c519 100644
--- a/api/datastore/test_data/output_24_to_latest.json
+++ b/api/datastore/test_data/output_24_to_latest.json
@@ -678,14 +678,11 @@
"Images": null,
"Info": {
"Architecture": "",
- "BridgeNfIp6tables": false,
- "BridgeNfIptables": false,
"CDISpecDirs": null,
"CPUSet": false,
"CPUShares": false,
"CgroupDriver": "",
"ContainerdCommit": {
- "Expected": "",
"ID": ""
},
"Containers": 0,
@@ -709,7 +706,6 @@
"IndexServerAddress": "",
"InitBinary": "",
"InitCommit": {
- "Expected": "",
"ID": ""
},
"Isolation": "",
@@ -738,7 +734,6 @@
},
"RegistryConfig": null,
"RuncCommit": {
- "Expected": "",
"ID": ""
},
"Runtimes": null,
diff --git a/api/http/handler/docker/dashboard.go b/api/http/handler/docker/dashboard.go
index ad0399569..97d40e069 100644
--- a/api/http/handler/docker/dashboard.go
+++ b/api/http/handler/docker/dashboard.go
@@ -6,6 +6,7 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
+ "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/volume"
portainer "github.com/portainer/portainer/api"
@@ -116,12 +117,12 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H
return err
}
- networks, err := cli.NetworkList(r.Context(), types.NetworkListOptions{})
+ networks, err := cli.NetworkList(r.Context(), network.ListOptions{})
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker networks", err)
}
- networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c types.NetworkResource) string {
+ networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c network.Summary) string {
return c.Name
})
if err != nil {
diff --git a/api/http/proxy/factory/docker/networks.go b/api/http/proxy/factory/docker/networks.go
index 95c96df81..cd94478d2 100644
--- a/api/http/proxy/factory/docker/networks.go
+++ b/api/http/proxy/factory/docker/networks.go
@@ -6,7 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
- "github.com/docker/docker/api/types"
+ "github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
@@ -20,7 +20,7 @@ const (
)
func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, endpointID portainer.EndpointID, networkID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
- network, err := dockerClient.NetworkInspect(context.Background(), networkID, types.NetworkInspectOptions{})
+ network, err := dockerClient.NetworkInspect(context.Background(), networkID, network.InspectOptions{})
if err != nil {
return nil, err
}
diff --git a/api/portainer.go b/api/portainer.go
index f63d04ddb..bb383e44c 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -9,6 +9,7 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
+ "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/api/types/volume"
gittypes "github.com/portainer/portainer/api/git/types"
@@ -245,7 +246,7 @@ type (
DockerSnapshotRaw struct {
Containers []DockerContainerSnapshot `json:"Containers" swaggerignore:"true"`
Volumes volume.ListResponse `json:"Volumes" swaggerignore:"true"`
- Networks []types.NetworkResource `json:"Networks" swaggerignore:"true"`
+ Networks []network.Summary `json:"Networks" swaggerignore:"true"`
Images []image.Summary `json:"Images" swaggerignore:"true"`
Info system.Info `json:"Info" swaggerignore:"true"`
Version types.Version `json:"Version" swaggerignore:"true"`
diff --git a/go.mod b/go.mod
index 3193c560c..3a2ba0bf5 100644
--- a/go.mod
+++ b/go.mod
@@ -6,25 +6,25 @@ require (
github.com/Masterminds/semver v1.5.0
github.com/Microsoft/go-winio v0.6.2
github.com/VictoriaMetrics/fastcache v1.12.0
- github.com/aws/aws-sdk-go-v2 v1.24.1
- github.com/aws/aws-sdk-go-v2/credentials v1.16.16
+ github.com/aws/aws-sdk-go-v2 v1.30.3
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.27
github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1
- github.com/aws/smithy-go v1.19.0
+ github.com/aws/smithy-go v1.20.3
github.com/cbroglie/mustache v1.4.0
- github.com/compose-spec/compose-go/v2 v2.4.5
+ github.com/compose-spec/compose-go/v2 v2.6.4
github.com/containers/image/v5 v5.30.1
github.com/coreos/go-semver v0.3.1
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5
- github.com/docker/cli v27.4.0+incompatible
- github.com/docker/compose/v2 v2.31.0
- github.com/docker/docker v27.4.0+incompatible
+ github.com/docker/cli v28.2.1+incompatible
+ github.com/docker/compose/v2 v2.36.2
+ github.com/docker/docker v28.2.1+incompatible
github.com/fvbommel/sortorder v1.1.0
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
github.com/go-git/go-git/v5 v5.13.0
github.com/go-ldap/ldap/v3 v3.4.1
github.com/gofrs/uuid v4.2.0+incompatible
github.com/golang-jwt/jwt/v4 v4.5.2
- github.com/google/go-cmp v0.6.0
+ github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/gorilla/csrf v1.7.3
github.com/gorilla/mux v1.8.1
@@ -33,7 +33,7 @@ require (
github.com/hashicorp/golang-lru v0.5.4
github.com/joho/godotenv v1.4.0
github.com/jpillora/chisel v1.10.0
- github.com/klauspost/compress v1.17.11
+ github.com/klauspost/compress v1.18.0
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
github.com/opencontainers/go-digest v1.0.0
github.com/orcaman/concurrent-map v1.0.0
@@ -46,20 +46,20 @@ require (
github.com/stretchr/testify v1.10.0
github.com/urfave/negroni v1.0.0
github.com/viney-shih/go-lock v1.1.1
- go.etcd.io/bbolt v1.3.11
- golang.org/x/crypto v0.36.0
- golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
- golang.org/x/mod v0.21.0
- golang.org/x/oauth2 v0.27.0
- golang.org/x/sync v0.12.0
+ go.etcd.io/bbolt v1.4.0
+ golang.org/x/crypto v0.37.0
+ golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
+ golang.org/x/mod v0.24.0
+ golang.org/x/oauth2 v0.29.0
+ golang.org/x/sync v0.14.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.2
- k8s.io/apimachinery v0.32.2
+ 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.2
+ k8s.io/client-go v0.32.3
k8s.io/kubectl v0.32.2
k8s.io/metrics v0.32.2
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
@@ -69,11 +69,12 @@ 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-20230811130428-ced1acdcaa24 // 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-20230124172434-306776ec8161 // 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/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
github.com/Masterminds/semver/v3 v3.3.0 // indirect
@@ -84,18 +85,19 @@ require (
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2 // indirect
+ github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
- github.com/aws/aws-sdk-go-v2/config v1.26.6 // indirect
- github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect
- github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect
- github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect
- github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/buger/goterm v1.0.4 // indirect
@@ -106,23 +108,25 @@ require (
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/api v1.7.19 // indirect
- github.com/containerd/continuity v0.4.4 // indirect
- github.com/containerd/errdefs v0.3.0 // 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
+ github.com/containerd/errdefs v1.0.0 // indirect
+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
- github.com/containerd/platforms v0.2.1 // indirect
- github.com/containerd/ttrpc v1.2.5 // indirect
- github.com/containerd/typeurl/v2 v2.2.0 // indirect
+ github.com/containerd/platforms v1.0.0-rc.1 // indirect
+ github.com/containerd/ttrpc v1.2.7 // indirect
+ github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect
- github.com/containers/ocicrypt v1.1.10 // 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
- github.com/docker/buildx v0.18.0 // indirect
- github.com/docker/cli-docs-tool v0.8.0 // indirect
+ github.com/docker/buildx v0.24.0 // indirect
+ github.com/docker/cli-docs-tool v0.9.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
- github.com/docker/docker-credential-helpers v0.8.2 // indirect
+ github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
@@ -136,7 +140,7 @@ require (
github.com/fatih/color v1.15.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsevents v0.2.0 // indirect
- github.com/fsnotify/fsnotify v1.7.0 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.1 // indirect
github.com/go-errors/errors v1.4.2 // indirect
@@ -152,6 +156,7 @@ require (
github.com/gobwas/glob v0.2.3 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
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
@@ -161,17 +166,18 @@ require (
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
- github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/in-toto/in-toto-golang v0.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
- github.com/jonboulle/clockwork v0.4.0 // indirect
+ github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/ansi v1.0.3 // indirect
github.com/jpillora/requestlog v1.0.0 // indirect
@@ -187,8 +193,8 @@ require (
github.com/lithammer/dedent v1.1.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
- github.com/mattn/go-isatty v0.0.17 // indirect
- github.com/mattn/go-runewidth v0.0.15 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
@@ -198,37 +204,38 @@ require (
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
- github.com/moby/buildkit v0.17.2 // indirect
+ github.com/moby/buildkit v0.22.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
+ github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/spdystream v0.5.0 // indirect
+ github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/capability v0.4.0 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/signal v0.7.1 // indirect
github.com/moby/sys/symlink v0.3.0 // indirect
- github.com/moby/sys/user v0.3.0 // indirect
+ github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
- github.com/moby/term v0.5.0 // indirect
+ github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
- github.com/opencontainers/image-spec v1.1.0 // indirect
- github.com/opencontainers/runtime-spec v1.2.0 // indirect
+ github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/opencontainers/runtime-spec v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
- github.com/prometheus/client_golang v1.19.1 // indirect
+ github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.55.0 // indirect
+ github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
- github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rubenv/sql-migrate v1.7.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -241,57 +248,59 @@ require (
github.com/skeema/knownhosts v1.3.0 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/spf13/cast v1.7.0 // indirect
- github.com/spf13/cobra v1.8.1 // indirect
- github.com/spf13/pflag v1.0.5 // indirect
+ github.com/spf13/cobra v1.9.1 // indirect
+ github.com/spf13/pflag v1.0.6 // indirect
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect
- github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
- github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c // indirect
+ github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect
+ github.com/tonistiigi/fsutil v0.0.0-20250417144416-3f76f8130144 // indirect
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
github.com/ulikunitz/xz v0.5.11 // indirect
- github.com/vbatts/tar-split v0.11.5 // indirect
+ github.com/vbatts/tar-split v0.12.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
+ github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
- go.opentelemetry.io/otel v1.28.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 // indirect
- go.opentelemetry.io/otel/metric v1.28.0 // indirect
- go.opentelemetry.io/otel/sdk v1.28.0 // indirect
- go.opentelemetry.io/otel/sdk/metric v1.28.0 // indirect
- go.opentelemetry.io/otel/trace v1.28.0 // indirect
- go.opentelemetry.io/proto/otlp v1.3.1 // indirect
- go.uber.org/mock v0.5.0 // indirect
- golang.org/x/net v0.38.0 // indirect
- golang.org/x/sys v0.31.0 // indirect
- golang.org/x/term v0.30.0 // indirect
- golang.org/x/text v0.23.0 // indirect
- golang.org/x/time v0.7.0 // indirect
- google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
- google.golang.org/grpc v1.68.0 // indirect
- google.golang.org/protobuf v1.35.1 // indirect
- gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
+ github.com/zclconf/go-cty v1.16.2 // indirect
+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
+ 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/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
+ go.opentelemetry.io/otel/metric v1.35.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.35.0 // indirect
+ go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
+ 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/sys v0.33.0 // indirect
+ golang.org/x/term v0.31.0 // indirect
+ golang.org/x/text v0.24.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
+ google.golang.org/grpc v1.72.1 // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
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.2 // indirect
- k8s.io/component-base v0.32.2 // indirect
+ k8s.io/apiserver v0.32.3 // indirect
+ k8s.io/component-base v0.32.3 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
@@ -301,5 +310,5 @@ require (
sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
- tags.cncf.io/container-device-interface v0.8.0 // indirect
+ tags.cncf.io/container-device-interface v1.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 3f4be510b..b79c1d644 100644
--- a/go.sum
+++ b/go.sum
@@ -2,14 +2,12 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
-github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 h1:59MxjQVfjXsBpLy+dbd2/ELV5ofnUkUZBvWSC85sheA=
-github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
-github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
-github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
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=
@@ -17,6 +15,8 @@ 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/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=
+github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e/go.mod h1:blbwPQh4DTlCZEfk1BLU4oMIhLda2U+A840Uag9DsZw=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
@@ -32,8 +32,8 @@ github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA4
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
-github.com/Microsoft/hcsshim v0.12.5 h1:bpTInLlDy/nDRWFVcefDZZ1+U8tS+rz3MxjKgu9boo0=
-github.com/Microsoft/hcsshim v0.12.5/go.mod h1:tIUGego4G1EN5Hb6KC90aDYiUI2dqLSTTOCjVNpOgZ8=
+github.com/Microsoft/hcsshim v0.13.0 h1:/BcXOiS6Qi7N9XqUcv27vkIuVOkBEcWstd2pMlWSeaA=
+github.com/Microsoft/hcsshim v0.13.0/go.mod h1:9KWJ/8DgU+QzYGupX4tzMhRQE8h6w90lH6HAaclpEok=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk=
@@ -59,38 +59,40 @@ github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2 h1:axBiC50cNZ
github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2/go.mod h1:jnzFpU88PccN/tPPhCpnNU8mZphvKxYM9lLNkd8e+os=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
+github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
-github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU=
-github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
-github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o=
-github.com/aws/aws-sdk-go-v2/config v1.26.6/go.mod h1:uKU6cnDmYCvJ+pxO9S4cWDb2yWWIH5hra+32hVh1MI4=
-github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8=
-github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls=
-github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
+github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
+github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
+github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
+github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1 h1:zqXEIhuR7RcHob2gxB/Xf1X4XuMS0vapn7xr+wCPrpg=
github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1/go.mod h1:+rWYJfms9p+D/wUN599tx3FtWvxoXCP25b8Porlrxcc=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino=
-github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow=
-github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8=
-github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0=
-github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U=
-github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
-github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII=
+github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
+github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
+github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
+github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
+github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/beorn7/perks v0.0.0-20150223135152-b965b613227f/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
@@ -127,52 +129,58 @@ github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vc
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
-github.com/compose-spec/compose-go/v2 v2.4.5 h1:p4ih4Jb6VgGPLPxh3fSFVKAjFHtZd+7HVLCSFzcFx9Y=
-github.com/compose-spec/compose-go/v2 v2.4.5/go.mod h1:lFN0DrMxIncJGYAXTfWuajfwj5haBJqrBkarHcnjJKc=
+github.com/compose-spec/compose-go/v2 v2.6.4 h1:Gjv6x8eAhqwwWvoXIo0oZ4bDQBh0OMwdU7LUL9PDLiM=
+github.com/compose-spec/compose-go/v2 v2.6.4/go.mod h1:vPlkN0i+0LjLf9rv52lodNMUTJF5YHVfHVGLLIP67NA=
github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
-github.com/containerd/cgroups/v3 v3.0.2 h1:f5WFqIVSgo5IZmtTT3qVBo6TzI1ON6sycSBKkymb9L0=
-github.com/containerd/cgroups/v3 v3.0.2/go.mod h1:JUgITrzdFqp42uI2ryGA+ge0ap/nxzYgkGmIcetmErE=
+github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo=
+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/api v1.7.19 h1:VWbJL+8Ap4Ju2mx9c9qS1uFSB1OVYr5JJrW2yT5vFoA=
-github.com/containerd/containerd/api v1.7.19/go.mod h1:fwGavl3LNwAV5ilJ0sbrABL44AQxmNjDRcwheXDb6Ig=
-github.com/containerd/continuity v0.4.4 h1:/fNVfTJ7wIl/YPMHjf+5H32uFhl63JucB34PlCpMKII=
-github.com/containerd/continuity v0.4.4/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
-github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4=
-github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+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=
+github.com/containerd/containerd/v2 v2.1.1/go.mod h1:zIfkQj4RIodclYQkX7GSSswSwgP8d/XxDOtOAoSDIGU=
+github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
+github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY=
github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
-github.com/containerd/nydus-snapshotter v0.14.0 h1:6/eAi6d7MjaeLLuMO8Udfe5GVsDudmrDNO4SGETMBco=
-github.com/containerd/nydus-snapshotter v0.14.0/go.mod h1:TT4jv2SnIDxEBu4H2YOvWQHPOap031ydTaHTuvc5VQk=
-github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
-github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
-github.com/containerd/stargz-snapshotter v0.15.1 h1:fpsP4kf/Z4n2EYnU0WT8ZCE3eiKDwikDhL6VwxIlgeA=
-github.com/containerd/stargz-snapshotter/estargz v0.15.1 h1:eXJjw9RbkLFgioVaTG+G/ZW/0kEe2oEKCdS/ZxIyoCU=
-github.com/containerd/stargz-snapshotter/estargz v0.15.1/go.mod h1:gr2RNwukQ/S9Nv33Lt6UC7xEx58C+LHRdoqbEKjz1Kk=
-github.com/containerd/ttrpc v1.2.5 h1:IFckT1EFQoFBMG4c3sMdT8EP3/aKfumK1msY+Ze4oLU=
-github.com/containerd/ttrpc v1.2.5/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o=
-github.com/containerd/typeurl/v2 v2.2.0 h1:6NBDbQzr7I5LHgp34xAXYF5DOTQDn05X58lsPEmzLso=
-github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g=
+github.com/containerd/nydus-snapshotter v0.15.0 h1:RqZRs1GPeM6T3wmuxJV9u+2Rg4YETVMwTmiDeX+iWC8=
+github.com/containerd/nydus-snapshotter v0.15.0/go.mod h1:biq0ijpeZe0I5yZFSJyHzFSjjRZQ7P7y/OuHyd7hYOw=
+github.com/containerd/platforms v1.0.0-rc.1 h1:83KIq4yy1erSRgOVHNk1HYdPvzdJ5CnsWaRoJX4C41E=
+github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
+github.com/containerd/plugin v1.0.0 h1:c8Kf1TNl6+e2TtMHZt+39yAPDbouRH9WAToRjex483Y=
+github.com/containerd/plugin v1.0.0/go.mod h1:hQfJe5nmWfImiqT1q8Si3jLv3ynMUIBB47bQ+KexvO8=
+github.com/containerd/stargz-snapshotter v0.16.3 h1:zbQMm8dRuPHEOD4OqAYGajJJUwCeUzt4j7w9Iaw58u4=
+github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
+github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
+github.com/containerd/ttrpc v1.2.7 h1:qIrroQvuOL9HQ1X6KHe2ohc7p+HP/0VE6XPU7elJRqQ=
+github.com/containerd/ttrpc v1.2.7/go.mod h1:YCXHsb32f+Sq5/72xHubdiJRQY9inL4a4ZQrAbN1q9o=
+github.com/containerd/typeurl/v2 v2.2.3 h1:yNA/94zxWdvYACdYO8zofhrTVuQY73fFU1y++dYSw40=
+github.com/containerd/typeurl/v2 v2.2.3/go.mod h1:95ljDnPfD3bAbDJRugOiShd/DlAAsxGtUBhJxIn7SCk=
github.com/containers/image/v5 v5.30.1 h1:AKrQMgOKI1oKx5FW5eoU2xoNyzACajHGx1O3qxobvFM=
github.com/containers/image/v5 v5.30.1/go.mod h1:gSD8MVOyqBspc0ynLsuiMR9qmt8UQ4jpVImjmK0uXfk=
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 h1:Qzk5C6cYglewc+UyGf6lc8Mj2UaPTHy/iF2De0/77CA=
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01/go.mod h1:9rfv8iPl1ZP7aqh9YA68wnZv2NUDbXdcdPHVz0pFbPY=
-github.com/containers/ocicrypt v1.1.10 h1:r7UR6o8+lyhkEywetubUUgcKFjOWOaWz8cEBrCPX0ic=
-github.com/containers/ocicrypt v1.1.10/go.mod h1:YfzSSr06PTHQwSTUKqDSjish9BeW1E4HUmreluQcMd8=
+github.com/containers/ocicrypt v1.2.1 h1:0qIOTT9DoYwcKmxSt8QJt+VzMY18onl9jUXsxpVhSmM=
+github.com/containers/ocicrypt v1.2.1/go.mod h1:aD0AAqfMp0MtwqWgHM1bUwe1anx0VazI108CRrSKINQ=
github.com/containers/storage v1.53.0 h1:VSES3C/u1pxjTJIXvLrSmyP7OBtDky04oGu07UvdTEA=
github.com/containers/storage v1.53.0/go.mod h1:pujcoOSc+upx15Jirdkebhtd8uJiLwbSd/mYT6zDJK8=
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/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+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.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
-github.com/creack/pty v1.1.21/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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -186,21 +194,21 @@ github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2 h1:aB
github.com/distribution/distribution/v3 v3.0.0-20221208165359-362910506bc2/go.mod h1:WHNsWjnIn2V1LYOrME7e8KxSeKunYHsxEm4am0BUtcI=
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.18.0 h1:rSauXHeJt90NvtXrLK5J992Eb0UPJZs2vV3u1zTf1nE=
-github.com/docker/buildx v0.18.0/go.mod h1:JGNSshOhHs5FhG3u51jXUf4lLOeD2QBIlJ2vaRB67p4=
-github.com/docker/cli v27.4.0+incompatible h1:/nJzWkcI1MDMN+U+px/YXnQWJqnu4J+QKGTfD6ptiTc=
-github.com/docker/cli v27.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
-github.com/docker/cli-docs-tool v0.8.0 h1:YcDWl7rQJC3lJ7WVZRwSs3bc9nka97QLWfyJQli8yJU=
-github.com/docker/cli-docs-tool v0.8.0/go.mod h1:8TQQ3E7mOXoYUs811LiPdUnAhXrcVsBIrW21a5pUbdk=
-github.com/docker/compose/v2 v2.31.0 h1:8Sm0c4MjIhksguxIA5koYMXoTJDAp/CaZ1cdZrMvMdw=
-github.com/docker/compose/v2 v2.31.0/go.mod h1:oQq3UDEdsnB3AUO72AxaoeLbkCgmUu1+8tLzvmphmXA=
+github.com/docker/buildx v0.24.0 h1:qiD+xktY+Fs3R79oz8M+7pbhip78qGLx6LBuVmyb+64=
+github.com/docker/buildx v0.24.0/go.mod h1:vYkdBUBjFo/i5vUE0mkajGlk03gE0T/HaGXXhgIxo8E=
+github.com/docker/cli v28.2.1+incompatible h1:AYyTcuwvhl9dXdyCiXlOGXiIqSNYzTmaDNpxIISPGsM=
+github.com/docker/cli v28.2.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli-docs-tool v0.9.0 h1:CVwQbE+ZziwlPqrJ7LRyUF6GvCA+6gj7MTCsayaK9t0=
+github.com/docker/cli-docs-tool v0.9.0/go.mod h1:ClrwlNW+UioiRyH9GiAOe1o3J/TsY3Tr1ipoypjAUtc=
+github.com/docker/compose/v2 v2.36.2 h1:rxk1PUUbhbAS6HkGsYo9xUmMBpKtVwFMNCQjE4+i5fk=
+github.com/docker/compose/v2 v2.36.2/go.mod h1:mZygkne+MAMu/e1B28PBFmG0Z0WefbxZ/IpcjSFdrw8=
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
-github.com/docker/docker v27.4.0+incompatible h1:I9z7sQ5qyzO0BfAb9IMOawRkAGxhYsidKiTMcm0DU+A=
-github.com/docker/docker v27.4.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
-github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
-github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
+github.com/docker/docker v28.2.1+incompatible h1:aTSWVTDStpHbnRu0xBcGoJEjRf5EQKt6nik6Vif8sWw=
+github.com/docker/docker v28.2.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
+github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0=
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
@@ -242,8 +250,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
github.com/fsnotify/fsevents v0.2.0 h1:BRlvlqjvNTfogHfeBOFvSC9N0Ddy+wzQCQukyoD7o/c=
github.com/fsnotify/fsevents v0.2.0/go.mod h1:B3eEk39i4hz8y1zaWS/wPrAP4O6wkIl7HQwKBr1qH/w=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
@@ -305,6 +313,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -327,8 +337,8 @@ github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvR
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -353,8 +363,8 @@ 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=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -378,6 +388,8 @@ github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s=
+github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jinzhu/gorm v0.0.0-20170222002820-5409931a1bb8 h1:CZkYfurY6KGhVtlalI4QwQ6T0Cu6iuY3e0x5RLu96WE=
@@ -393,8 +405,8 @@ github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
-github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
-github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
+github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
+github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/ansi v1.0.3 h1:nn4Jzti0EmRfDxm7JtEs5LzCbNwd5sv+0aE+LdS9/ZQ=
@@ -417,8 +429,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
-github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c h1:N7A4JCA2G+j5fuFxCsJqjFU/sZe0mj8H0sSoSwbaikw=
@@ -434,6 +446,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
@@ -445,8 +459,9 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY=
github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
-github.com/magiconair/properties v1.5.3 h1:C8fxWnhYyME3n0klPOhVM7PtYUB3eV1W3DeFmN3j53Y=
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
+github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@@ -456,10 +471,10 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
-github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
-github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
@@ -486,16 +501,20 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
-github.com/moby/buildkit v0.17.2 h1:/jgk/MuXbA7jeXMkknOpHYB+Ct4aNvQHkBB7SxD3D4U=
-github.com/moby/buildkit v0.17.2/go.mod h1:vr5vltV8wt4F2jThbNOChfbAklJ0DOW11w36v210hOg=
+github.com/moby/buildkit v0.22.0 h1:aWN06w1YGSVN1XfeZbj2ZbgY+zi5xDAjEFI8Cy9fTjA=
+github.com/moby/buildkit v0.22.0/go.mod h1:j4pP5hxiTWcz7xuTK2cyxQislHl/N2WWHzOy43DlLJw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
+github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
+github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
+github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk=
github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
@@ -506,12 +525,12 @@ github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0
github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8=
github.com/moby/sys/symlink v0.3.0 h1:GZX89mEZ9u53f97npBy4Rc3vJKj7JBDj/PN2I22GrNU=
github.com/moby/sys/symlink v0.3.0/go.mod h1:3eNdhduHmYPcgsJtZXW1W4XUJdZGBIkttZ8xKqPUJq0=
-github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
-github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
+github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
+github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
-github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
-github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -542,12 +561,12 @@ github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
-github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
-github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
-github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk=
-github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
-github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU=
-github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww=
+github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
+github.com/opencontainers/selinux v1.12.0 h1:6n5JV4Cf+4y0KNXW48TLj5DwfXpvWlxXplUkdTrmPb8=
+github.com/opencontainers/selinux v1.12.0/go.mod h1:BTPX+bjVbWGXw7ZZWUbdENt8w0htPSrlgOOysQaU62U=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
@@ -578,8 +597,8 @@ github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
-github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
-github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
+github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
+github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -588,23 +607,21 @@ github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQy
github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
-github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
-github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
+github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
+github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
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/r3labs/sse v0.0.0-20210224172625-26fe804710bc h1:zAsgcP8MhzAbhMnB1QQ2O7ZhWYVGYSR2iVcjzQuPV+o=
-github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc/go.mod h1:S8xSOnV3CgpNrWd0GQ/OoQfMtlg2uPRSuTzcSGrzwK8=
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=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
-github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
-github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
+github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
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=
@@ -642,13 +659,13 @@ github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94/go.mod h1:r2rcYCSwa1IEx
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
-github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
-github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431 h1:XTHrT015sxHyJ5FnQ0AeemSspZWaDq7DoTRW0EVsDCE=
github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c h1:2EejZtjFjKJGk71ANb+wtFK5EjUzUkEM3R0xnp559xg=
github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -677,10 +694,10 @@ github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 h1:QB54BJwA6x8
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375/go.mod h1:xRroudyp5iVtxKqZCrA6n2TLFRBf8bmnjr1UD4x+z7g=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
-github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 h1:eUk79E1w8yMtXeHSzjKorxuC8qJOnyXQnLaJehxpJaI=
-github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205/go.mod h1:3Iuxbr0P7D3zUzBMAZB+ois3h/et0shEz0qApgHYGpY=
-github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c h1:bQLsfX+uEPZUjyR2qmoJs5F8Z57bPVmF3IsUf22Xo9Y=
-github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c/go.mod h1:Dl/9oEjK7IqnjAm21Okx/XIxUCFJzvh+XdVHUlBwXTw=
+github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 h1:r0p7fK56l8WPequOaR3i9LBqfPtEdXIQbUTzT55iqT4=
+github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323/go.mod h1:3Iuxbr0P7D3zUzBMAZB+ois3h/et0shEz0qApgHYGpY=
+github.com/tonistiigi/fsutil v0.0.0-20250417144416-3f76f8130144 h1:k9tdF32oJYwtjzMx+D26M6eYiCaAPdJ7tyN7tF1oU5Q=
+github.com/tonistiigi/fsutil v0.0.0-20250417144416-3f76f8130144/go.mod h1:BKdcez7BiVtBvIcef90ZPc6ebqIWr4JWD7+EvLm6J98=
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 h1:7I5c2Ig/5FgqkYOh/N87NzoyI9U15qUPXhDD8uCupv8=
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE=
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea h1:SXhTLE6pb6eld/v/cCndK0AMpt1wiVFb/YYmqB3/QG0=
@@ -691,8 +708,8 @@ github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc=
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
-github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts=
-github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk=
+github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
+github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
github.com/viney-shih/go-lock v1.1.1 h1:SwzDPPAiHpcwGCr5k8xD15d2gQSo8d4roRYd7TDV2eI=
github.com/viney-shih/go-lock v1.1.1/go.mod h1:Yijm78Ljteb3kRiJrbLAxVntkUukGu5uzSxq/xV7OO8=
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b h1:FsyNrX12e5BkplJq7wKOLk0+C6LZ+KGXvuEcKUYm5ss=
@@ -708,6 +725,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
+github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
+github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -719,46 +738,50 @@ github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMzt
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=
github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc/go.mod h1:FM4U1E3NzlNMRnSUTU3P1UdukWhYGifqEsjk9fn7BCk=
github.com/zmap/zlint/v3 v3.1.0 h1:WjVytZo79m/L1+/Mlphl09WBob6YTGljN5IGWZFpAv0=
github.com/zmap/zlint/v3 v3.1.0/go.mod h1:L7t8s3sEKkb0A2BxGy1IWrxt1ZATa1R4QfJZaQOD3zU=
-go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
-go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
+go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
+go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
-go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 h1:gbhw/u49SS3gkPWiYweQNJGm/uJN5GkI/FrosxSHT7A=
-go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1/go.mod h1:GnOaBaFQ2we3b9AGWJpsBa7v1S5RlQzlC3O7dRMxZhM=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
-go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
-go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0 h1:jd0+5t/YynESZqsSyPz+7PAFdEop0dlN0+PkyHYo8oI=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.44.0/go.mod h1:U707O40ee1FpQGyhvqnzmCJm1Wh6OX6GGBVn0E6Uyyk=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0 h1:bflGWrfYyuulcdxf14V6n9+CoQcu5SAAdHmDPAJnlps=
-go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.44.0/go.mod h1:qcTO4xHAxZLaLxPd60TdE88rxtItPHgHWqOhOGRr0as=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0 h1:Mbi5PKN7u322woPa85d7ebZ+SOvEoPvoiBu+ryHWgfA=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.25.0/go.mod h1:e7ciERRhZaOZXVjx5MiL8TK5+Xv7G5Gv5PA2ZDEJdL8=
-go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
-go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
-go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
-go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
-go.opentelemetry.io/otel/sdk/metric v1.28.0 h1:OkuaKgKrgAbYrrY0t92c+cC+2F6hsFNnCQArXCKlg08=
-go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg=
-go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
-go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
-go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
-go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
+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/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=
+go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.56.0/go.mod h1:3qi2EEwMgB4xnKgPLqsDP3j9qxnHDZeHsnAxfjQqTko=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
+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/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/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/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=
+go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
+go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
+go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
-go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
-go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
+go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
+go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -770,30 +793,29 @@ 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.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
-golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
-golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
-golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
+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/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.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
-golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+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/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=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20191116160921-f9c825593386/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
-golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
-golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
-golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
+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/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=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -802,8 +824,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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
-golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
+golang.org/x/sync v0.14.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=
@@ -830,49 +852,46 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+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.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
-golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
-golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
-golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+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=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
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.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
-golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
+golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
+golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
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=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY=
-google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo=
-google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
-google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
+google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
-google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
-google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
-google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
-google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
+google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
-gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y=
-gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI=
gopkg.in/cenkalti/backoff.v2 v2.2.1 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII=
gopkg.in/cenkalti/backoff.v2 v2.2.1/go.mod h1:S0QdOvT2AlerfSBkp0O+dk+bbIMaNbEmVk876gPCthU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -886,6 +905,8 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.1 h1:d4KQkxAaAiRY2h5Zqis161Pv91A37uZyJOx73duwUwM=
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.1/go.mod h1:WbjuEoo1oadwzQ4apSDU+JTvmllEHtsNHS6y7vFc7iw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
@@ -901,24 +922,24 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
-gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
+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.2 h1:bZrMLEkgizC24G9eViHGOPbW+aRo9duEISRIJKfdJuw=
-k8s.io/api v0.32.2/go.mod h1:hKlhk4x1sJyYnHENsrdCWw31FEmCijNGPJO5WzHiJ6Y=
+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.2 h1:yoQBR9ZGkA6Rgmhbp/yuT9/g+4lxtsGYwW6dR6BDPLQ=
-k8s.io/apimachinery v0.32.2/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
-k8s.io/apiserver v0.32.2 h1:WzyxAu4mvLkQxwD9hGa4ZfExo3yZZaYzoYvvVDlM6vw=
-k8s.io/apiserver v0.32.2/go.mod h1:PEwREHiHNU2oFdte7BjzA1ZyjWjuckORLIK/wLV5goM=
+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.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA=
-k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94=
-k8s.io/component-base v0.32.2 h1:1aUL5Vdmu7qNo4ZsE+569PV5zFatM9hl+lb3dEea2zU=
-k8s.io/component-base v0.32.2/go.mod h1:PXJ61Vx9Lg+P5mS8TLd7bCIr+eMJRQTyXe8KvkrvJq0=
+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=
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=
@@ -943,5 +964,5 @@ 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=
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78/go.mod h1:B7Wf0Ya4DHF9Yw+qfZuJijQYkWicqDa+79Ytmmq3Kjg=
-tags.cncf.io/container-device-interface v0.8.0 h1:8bCFo/g9WODjWx3m6EYl3GfUG31eKJbaggyBDxEldRc=
-tags.cncf.io/container-device-interface v0.8.0/go.mod h1:Apb7N4VdILW0EVdEMRYXIDVRZfNJZ+kmEUss2kRRQ6Y=
+tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc=
+tags.cncf.io/container-device-interface v1.0.1/go.mod h1:JojJIOeW3hNbcnOH2q0NrWNha/JuHoDZcmYxAZwb2i0=
diff --git a/pkg/snapshot/docker.go b/pkg/snapshot/docker.go
index 810dbeaa9..deaabdb08 100644
--- a/pkg/snapshot/docker.go
+++ b/pkg/snapshot/docker.go
@@ -16,6 +16,7 @@ import (
"github.com/docker/docker/api/types"
dockercontainer "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
+ "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
@@ -244,7 +245,7 @@ func dockerSnapshotVolumes(snapshot *portainer.DockerSnapshot, cli *client.Clien
}
func dockerSnapshotNetworks(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
- networks, err := cli.NetworkList(context.Background(), types.NetworkListOptions{})
+ networks, err := cli.NetworkList(context.Background(), network.ListOptions{})
if err != nil {
return err
}
From caac45b834db853fc42cdf0517b0ee674f5ba08e Mon Sep 17 00:00:00 2001
From: James Player
Date: Thu, 5 Jun 2025 10:14:39 +1200
Subject: [PATCH 136/212] feat(UI): Add repository url to Helm chart
installation list items (#769)
---
.../HelmTemplates/HelmTemplatesListItem.tsx | 15 ++++---------
.../HelmTemplatesSelectedItem.test.tsx | 2 +-
.../HelmTemplatesSelectedItem.tsx | 22 +++++--------------
3 files changed, 11 insertions(+), 28 deletions(-)
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx
index de8272bcb..a1ee6a444 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesListItem.tsx
@@ -2,8 +2,6 @@ import React from 'react';
import { FallbackImage } from '@/react/components/FallbackImage';
-import Svg from '@@/Svg';
-
import { Chart } from '../types';
import { HelmIcon } from './HelmIcon';
@@ -40,15 +38,10 @@ export function HelmTemplatesListItem(props: HelmTemplatesListItemProps) {
-
- {model.name}
-
-
-
-
- Helm
-
-
+
+
{model.name}
+
{model.repo}
+
{actions}
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
index 215838ff8..f7328cc4d 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.test.tsx
@@ -99,7 +99,7 @@ describe('HelmTemplatesSelectedItem', () => {
expect(screen.getByText('test-chart')).toBeInTheDocument();
expect(screen.getByText('Test Chart Description')).toBeInTheDocument();
expect(screen.getByText('Clear selection')).toBeInTheDocument();
- expect(screen.getByText('Helm')).toBeInTheDocument();
+ expect(screen.getByText('https://example.com')).toBeInTheDocument();
});
it('should toggle custom values editor', async () => {
diff --git a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
index baffb9655..837659fcd 100644
--- a/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
+++ b/app/react/kubernetes/helm/HelmTemplates/HelmTemplatesSelectedItem.tsx
@@ -10,7 +10,6 @@ import { useCanExit } from '@/react/hooks/useCanExit';
import { Widget } from '@@/Widget';
import { Button } from '@@/buttons/Button';
import { FallbackImage } from '@@/FallbackImage';
-import Svg from '@@/Svg';
import { Icon } from '@@/Icon';
import { WebEditorForm } from '@@/WebEditorForm';
import { confirmGenericDiscard } from '@@/modals/confirm';
@@ -100,22 +99,13 @@ export function HelmTemplatesSelectedItem({
className="h-16 w-16"
/>
-
-
-
- {selectedChart.name}
-
-
-
-
- {' '}
- Helm
-
-
-
-
- {selectedChart.description}
+
+
{selectedChart.name}
+
+ {selectedChart.repo}
+
+
{selectedChart.description}
From a9061e525892b981013c1d0f85680374aa71f471 Mon Sep 17 00:00:00 2001
From: Ali <83188384+testA113@users.noreply.github.com>
Date: Thu, 5 Jun 2025 13:13:45 +1200
Subject: [PATCH 137/212] feat(helm): enhance helm chart install [r8s-341]
(#766)
---
api/http/handler/helm/helm_show.go | 7 +
app/kubernetes/views/deploy/deploy.html | 10 +-
.../custom-template-selector.html | 4 +-
app/portainer/react/components/index.ts | 1 +
.../CodeEditor/CodeEditor.module.css | 8 +-
.../components/CodeEditor/CodeEditor.tsx | 60 +++---
.../CodeEditor/ShortcutsTooltip.tsx | 52 +++++
app/react/components/ExternalLink.tsx | 32 +++
app/react/components/WebEditorForm.tsx | 60 +-----
app/react/components/modals/Modal/Modal.tsx | 3 +-
.../DeployView/StackName/StackName.tsx | 2 +-
.../ChartActions/UpgradeButton.test.tsx | 49 +++--
.../ChartActions/UpgradeButton.tsx | 154 ++++++++-------
.../ChartActions/UpgradeHelmModal.tsx | 156 ++++++++-------
.../HelmTemplates/HelmInstallForm.test.tsx | 174 ++++++++++++++++
.../helm/HelmTemplates/HelmInstallForm.tsx | 106 ++++++++++
.../HelmTemplates/HelmInstallInnerForm.tsx | 86 ++++++++
.../helm/HelmTemplates/HelmTemplates.tsx | 18 +-
.../HelmTemplatesSelectedItem.test.tsx | 86 +-------
.../HelmTemplatesSelectedItem.tsx | 185 +++---------------
.../queries/useHelmChartInstall.ts | 40 ----
.../kubernetes/helm/HelmTemplates/types.ts | 4 +
.../helm/components/HelmValuesInput.tsx | 80 ++++++++
.../queries/useHelmChartValues.ts | 22 ++-
.../queries/useHelmRepositories.ts | 2 +-
.../queries/useUpdateHelmReleaseMutation.ts | 11 +-
app/react/kubernetes/helm/types.ts | 11 ++
pkg/libhelm/options/show_options.go | 1 +
pkg/libhelm/sdk/show.go | 2 +-
29 files changed, 864 insertions(+), 562 deletions(-)
create mode 100644 app/react/components/CodeEditor/ShortcutsTooltip.tsx
create mode 100644 app/react/components/ExternalLink.tsx
create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.test.tsx
create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmInstallForm.tsx
create mode 100644 app/react/kubernetes/helm/HelmTemplates/HelmInstallInnerForm.tsx
delete mode 100644 app/react/kubernetes/helm/HelmTemplates/queries/useHelmChartInstall.ts
create mode 100644 app/react/kubernetes/helm/HelmTemplates/types.ts
create mode 100644 app/react/kubernetes/helm/components/HelmValuesInput.tsx
rename app/react/kubernetes/helm/{HelmTemplates => }/queries/useHelmChartValues.ts (54%)
rename app/react/kubernetes/helm/{HelmApplicationView => }/queries/useHelmRepositories.ts (97%)
rename app/react/kubernetes/helm/{HelmApplicationView => }/queries/useUpdateHelmReleaseMutation.ts (84%)
diff --git a/api/http/handler/helm/helm_show.go b/api/http/handler/helm/helm_show.go
index 591c57922..f139827b8 100644
--- a/api/http/handler/helm/helm_show.go
+++ b/api/http/handler/helm/helm_show.go
@@ -20,6 +20,7 @@ import (
// @tags helm
// @param repo query string true "Helm repository URL"
// @param chart query string true "Chart name"
+// @param version query string true "Chart version"
// @param command path string true "chart/values/readme"
// @security ApiKeyAuth
// @security jwt
@@ -45,6 +46,11 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
return httperror.BadRequest("Bad request", errors.New("missing `chart` query parameter"))
}
+ version, err := request.RetrieveQueryParameter(r, "version", true)
+ if err != nil {
+ return httperror.BadRequest("Bad request", errors.Wrap(err, fmt.Sprintf("provided version %q is not valid", version)))
+ }
+
cmd, err := request.RetrieveRouteVariableValue(r, "command")
if err != nil {
cmd = "all"
@@ -55,6 +61,7 @@ func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httper
OutputFormat: options.ShowOutputFormat(cmd),
Chart: chart,
Repo: repo,
+ Version: version,
}
result, err := handler.helmPackageManager.Show(showOptions)
if err != nil {
diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html
index 5eda2d3a7..d57d0caa7 100644
--- a/app/kubernetes/views/deploy/deploy.html
+++ b/app/kubernetes/views/deploy/deploy.html
@@ -31,7 +31,7 @@
-