Compare commits

...

44 Commits

Author SHA1 Message Date
Steven Kang
27cbc3818e version: bump version to 2.27.0-rc1 (#363)
Co-authored-by: steven <steven@stevens-Mini.hub>
2025-02-03 12:36:29 +13:00
James Player
e943aa8f03 feat(documentation): change docs to use LTS/STS instead of version number (#357) 2025-02-03 11:17:36 +13:00
James Player
17a4750d8e fix(kubernetes): Resource reservation wasn't displaying properly in business edition and remove leader status (#362) 2025-02-03 11:02:23 +13:00
Malcolm Lockyer
7d18c22aa1 fix(ui): bring back k8s applications page row expand published urls [r8s-145] (#356) 2025-01-31 13:16:18 +13:00
Ali
c80cc6e268 chore(automation): give unique selectors [r8s-168] (#345)
Co-authored-by: JamesPlayer <james.player@portainer.io>
2025-01-30 15:42:32 +13:00
andres-portainer
b30a1b5250 fix(edgestacks): avoid repeated statuses BE-11561 (#351) 2025-01-27 16:00:05 -03:00
LP B
b753371700 fix(app/edge-stack): edge stack create form validation (#343) 2025-01-24 17:02:52 +01:00
andres-portainer
3ca5ab180f fix(system): optimize the memory usage when counting nodes BE-11575 (#342) 2025-01-23 20:41:09 -03:00
Ali
4971f5510c fix(app): edit app with configmap [r8s-95] (#341) 2025-01-24 11:35:47 +13:00
andres-portainer
20fa7e508d fix(edgestacks): decouple the EdgeStackStatusUpdateCoordinator so it can be used by other packages BE-11572 (#340) 2025-01-23 17:10:46 -03:00
James Player
ebffc340d9 fix(k8s): Changed 'Deploy from file' button text to 'Deploy from code' (#338) 2025-01-23 16:47:52 +13:00
andres-portainer
9a86737caa fix(edgestacks): add a status update coordinator to increase performance BE-11572 (#337) 2025-01-22 20:24:54 -03:00
Steven Kang
d35d8a7307 feat(oauth): fix mapping (#330) 2025-01-23 09:03:51 +13:00
andres-portainer
701ff5d6bc refactor(edgestacks): move handlerDBErr() out of the handler BE-11572 (#336) 2025-01-22 16:35:06 -03:00
LP B
9044b25a23 fix(app): remove passwords from registries list response (#334) 2025-01-22 17:40:21 +01:00
Ali
7f089fab86 fix(apps): use replicas from application spec [r8s-142] (#335) 2025-01-22 12:31:27 +13:00
James Carppe
a259c28678 Update bug report template for 2.26.1 (#329) 2025-01-21 16:19:03 +13:00
LP B
db48da185a fix(app/editor): reduce editor slowness by debouncing onChange calls (#326) 2025-01-17 22:41:06 +01:00
LP B
cab667c23b fix(app/edge-stack): UI notification on creation error (#325) 2025-01-17 20:33:01 +01:00
andres-portainer
154ca9f1b1 fix(edge): return proper error from context BE-11564 (#323) 2025-01-16 20:18:51 -03:00
Oscar Zhou
2abe40b786 fix(edgestack): remove project folder after deleting edgestack [BE-11559] (#320) 2025-01-16 09:16:09 +13:00
James Carppe
6be2420b32 Update bug report template for 2.26.0 (#319) 2025-01-15 14:38:59 +13:00
Ali
9405cc0e04 chore(portainer): bump version to 2.26.0 (#302) 2025-01-14 07:20:11 +13:00
Yajith Dayarathna
55c98912ed feat(omni): support for omni [R8S-75] (#105)
Co-authored-by: stevensbkang <skan070@gmail.com>
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2025-01-13 17:06:10 +13:00
Ali
45bd7984b0 fit(jobs): remove redundant checkboxes in executions datatable [r8s-182] (#295) 2025-01-12 18:24:22 +13:00
andres-portainer
1ed9a0106e feat(edge): optimize Edge Stack retrieval BE-11555 (#294) 2025-01-10 16:44:19 -03:00
LP B
f8b2ee8c0d fix(app/edge-stack): local filesystem path is not retained (#292) 2025-01-10 18:20:44 +01:00
Steven Kang
d32b0f8b7e feat(kubernetes): support for jobs and cron jobs - r8s-182 (#260)
Co-authored-by: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Co-authored-by: Anthony Lapenna <anthony.lapenna@portainer.io>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
Co-authored-by: testA113 <aliharriss1995@gmail.com>
2025-01-10 13:21:27 +13:00
andres-portainer
24fdb1f600 fix(libstack): redirect the Docker and Compose logging to zerolog BE-11518 (#289) 2025-01-08 16:26:04 -03:00
Oscar Zhou
4010174f66 fix(docker/volume): failed to list volume before snapshot is created [BE-11544] (#286) 2025-01-08 09:45:13 +13:00
andres-portainer
e2b812a611 fix(edgestacks): check the version of the edge stack before updating the status BE-11488 (#287) 2025-01-07 17:31:57 -03:00
andres-portainer
d72b3a9ba2 feat(edgestacks): optimize the Edge Stack status update endpoint BE-11539 (#279) 2025-01-06 15:39:24 -03:00
LP B
85f52d2574 feat(app/stack): ability to prune volumes on stack/edge stack delete (#232)
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2025-01-01 10:44:49 +13:00
andres-portainer
33ea22c0a9 feat(ssl): improve caching behavior BE-11527 (#273) 2024-12-30 11:10:13 -03:00
andres-portainer
0d52f9dd0e feat(async): avoid sending CSRF token for async edge polling requests BE-1152 (#272) 2024-12-30 10:58:44 -03:00
andres-portainer
3caffe1e85 feat(async): filter out Docker snapshot diffs without meaningful changes BE-11527 (#265) 2024-12-26 18:45:20 -03:00
Oscar Zhou
87b8dd61c3 fix: replace strings.ToLower with strings.EqualFold [BE-11524] (#263) 2024-12-24 11:15:16 +13:00
andres-portainer
ad77cd195c fix(docker): fix a data race in the Docker transport BE-10873 (#255) 2024-12-23 09:54:11 -03:00
James Carppe
eb2a754580 Update bug report template for 2.21.5 / 2.25.1 (#261) 2024-12-20 14:39:33 +13:00
Steven Kang
9258db58db feat(auth): add 30m session timeout - r8s-178 (#259) 2024-12-20 10:49:13 +13:00
andres-portainer
8d1c90f912 fix(platform): fix a data race in GetPlatform() BE-11522 (#253) 2024-12-19 09:37:50 -03:00
Steven Kang
1c62bd6ca5 fix: security - CVE-2024-45337 - portainer-suite develop (#247) 2024-12-19 10:55:34 +13:00
andres-portainer
13317ec43c feat(stacks): simplify WaitForStatus() BE-11505 (#241) 2024-12-17 16:25:49 -03:00
James Carppe
35dcb5ca46 Update bug report template for 2.25.0 (#245) 2024-12-16 13:53:15 +13:00
155 changed files with 2935 additions and 726 deletions

View File

@@ -95,10 +95,15 @@ 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.26.1'
- '2.26.0'
- '2.25.1'
- '2.25.0'
- '2.24.1'
- '2.24.0'
- '2.23.0'
- '2.22.0'
- '2.21.5'
- '2.21.4'
- '2.21.3'
- '2.21.2'
@@ -118,8 +123,6 @@ body:
- '2.18.3'
- '2.18.2'
- '2.18.1'
- '2.17.1'
- '2.17.0'
validations:
required: true

View File

@@ -20,8 +20,6 @@ linters-settings:
deny:
- pkg: 'encoding/json'
desc: 'use github.com/segmentio/encoding/json'
- pkg: 'github.com/sirupsen/logrus'
desc: 'logging is allowed only by github.com/rs/zerolog'
- pkg: 'golang.org/x/exp'
desc: 'exp is not allowed'
- pkg: 'github.com/portainer/libcrypto'

View File

@@ -19,7 +19,5 @@ func Confirm(message string) (bool, error) {
}
answer = strings.ReplaceAll(answer, "\n", "")
answer = strings.ToLower(answer)
return answer == "y" || answer == "yes", nil
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes"), nil
}

View File

@@ -15,7 +15,7 @@ type Service struct {
connection portainer.Connection
idxVersion map[portainer.EdgeStackID]int
mu sync.RWMutex
cacheInvalidationFn func(portainer.EdgeStackID)
cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)
}
func (service *Service) BucketName() string {
@@ -23,7 +23,7 @@ func (service *Service) BucketName() string {
}
// NewService creates a new instance of a service.
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.EdgeStackID)) (*Service, error) {
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)) (*Service, error) {
err := connection.SetServiceName(BucketName)
if err != nil {
return nil, err
@@ -36,7 +36,7 @@ func NewService(connection portainer.Connection, cacheInvalidationFn func(portai
}
if s.cacheInvalidationFn == nil {
s.cacheInvalidationFn = func(portainer.EdgeStackID) {}
s.cacheInvalidationFn = func(portainer.Transaction, portainer.EdgeStackID) {}
}
es, err := s.EdgeStacks()
@@ -106,7 +106,7 @@ func (service *Service) Create(id portainer.EdgeStackID, edgeStack *portainer.Ed
service.mu.Lock()
service.idxVersion[id] = edgeStack.Version
service.cacheInvalidationFn(id)
service.cacheInvalidationFn(service.connection, id)
service.mu.Unlock()
return nil
@@ -125,7 +125,7 @@ func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *por
}
service.idxVersion[ID] = edgeStack.Version
service.cacheInvalidationFn(ID)
service.cacheInvalidationFn(service.connection, ID)
return nil
}
@@ -142,7 +142,7 @@ func (service *Service) UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc
updateFunc(edgeStack)
service.idxVersion[ID] = edgeStack.Version
service.cacheInvalidationFn(ID)
service.cacheInvalidationFn(service.connection, ID)
})
}
@@ -165,7 +165,7 @@ func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error {
delete(service.idxVersion, ID)
service.cacheInvalidationFn(ID)
service.cacheInvalidationFn(service.connection, ID)
return nil
}

View File

@@ -44,8 +44,7 @@ func (service ServiceTx) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeSta
var stack portainer.EdgeStack
identifier := service.service.connection.ConvertToKey(int(ID))
err := service.tx.GetObject(BucketName, identifier, &stack)
if err != nil {
if err := service.tx.GetObject(BucketName, identifier, &stack); err != nil {
return nil, err
}
@@ -65,18 +64,17 @@ func (service ServiceTx) EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
func (service ServiceTx) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error {
edgeStack.ID = id
err := service.tx.CreateObjectWithId(
if err := service.tx.CreateObjectWithId(
BucketName,
int(edgeStack.ID),
edgeStack,
)
if err != nil {
); err != nil {
return err
}
service.service.mu.Lock()
service.service.idxVersion[id] = edgeStack.Version
service.service.cacheInvalidationFn(id)
service.service.cacheInvalidationFn(service.tx, id)
service.service.mu.Unlock()
return nil
@@ -89,13 +87,12 @@ func (service ServiceTx) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *po
identifier := service.service.connection.ConvertToKey(int(ID))
err := service.tx.UpdateObject(BucketName, identifier, edgeStack)
if err != nil {
if err := service.tx.UpdateObject(BucketName, identifier, edgeStack); err != nil {
return err
}
service.service.idxVersion[ID] = edgeStack.Version
service.service.cacheInvalidationFn(ID)
service.service.cacheInvalidationFn(service.tx, ID)
return nil
}
@@ -119,14 +116,13 @@ func (service ServiceTx) DeleteEdgeStack(ID portainer.EdgeStackID) error {
identifier := service.service.connection.ConvertToKey(int(ID))
err := service.tx.DeleteObject(BucketName, identifier)
if err != nil {
if err := service.tx.DeleteObject(BucketName, identifier); err != nil {
return err
}
delete(service.service.idxVersion, ID)
service.service.cacheInvalidationFn(ID)
service.service.cacheInvalidationFn(service.tx, ID)
return nil
}

View File

@@ -1,6 +1,8 @@
package endpointrelation
import (
"sync"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge/cache"
@@ -13,9 +15,11 @@ const BucketName = "endpoint_relations"
// Service represents a service for managing environment(endpoint) relation data.
type Service struct {
connection portainer.Connection
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
connection portainer.Connection
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
endpointRelationsCache []portainer.EndpointRelation
mu sync.Mutex
}
func (service *Service) BucketName() string {
@@ -76,6 +80,10 @@ func (service *Service) Create(endpointRelation *portainer.EndpointRelation) err
err := service.connection.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
cache.Del(endpointRelation.EndpointID)
service.mu.Lock()
service.endpointRelationsCache = nil
service.mu.Unlock()
return err
}
@@ -92,6 +100,10 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
updatedRelationState, _ := service.EndpointRelation(endpointID)
service.mu.Lock()
service.endpointRelationsCache = nil
service.mu.Unlock()
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
return nil
@@ -108,27 +120,15 @@ func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID)
return err
}
service.mu.Lock()
service.endpointRelationsCache = nil
service.mu.Unlock()
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
return nil
}
func (service *Service) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
rels, err := service.EndpointRelations()
if err != nil {
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
return
}
for _, rel := range rels {
for id := range rel.EdgeStacks {
if edgeStackID == id {
cache.Del(rel.EndpointID)
}
}
}
}
func (service *Service) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
relations, _ := service.EndpointRelations()

View File

@@ -45,6 +45,10 @@ func (service ServiceTx) Create(endpointRelation *portainer.EndpointRelation) er
err := service.tx.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
cache.Del(endpointRelation.EndpointID)
service.service.mu.Lock()
service.service.endpointRelationsCache = nil
service.service.mu.Unlock()
return err
}
@@ -61,6 +65,10 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
updatedRelationState, _ := service.EndpointRelation(endpointID)
service.service.mu.Lock()
service.service.endpointRelationsCache = nil
service.service.mu.Unlock()
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
return nil
@@ -77,27 +85,44 @@ func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID)
return err
}
service.service.mu.Lock()
service.service.endpointRelationsCache = nil
service.service.mu.Unlock()
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
return nil
}
func (service ServiceTx) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
rels, err := service.EndpointRelations()
rels, err := service.cachedEndpointRelations()
if err != nil {
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
return
}
for _, rel := range rels {
for id := range rel.EdgeStacks {
if edgeStackID == id {
cache.Del(rel.EndpointID)
}
if _, ok := rel.EdgeStacks[edgeStackID]; ok {
cache.Del(rel.EndpointID)
}
}
}
func (service ServiceTx) cachedEndpointRelations() ([]portainer.EndpointRelation, error) {
service.service.mu.Lock()
defer service.service.mu.Unlock()
if service.service.endpointRelationsCache == nil {
var err error
service.service.endpointRelationsCache, err = service.EndpointRelations()
if err != nil {
return nil, err
}
}
return service.service.endpointRelationsCache, nil
}
func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
relations, _ := service.EndpointRelations()
@@ -133,6 +158,7 @@ func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationSta
}
numDeployments := 0
for _, r := range relations {
for sId, enabled := range r.EdgeStacks {
if enabled && sId == refStackId {

View File

@@ -100,7 +100,9 @@ func (store *Store) initServices() error {
}
store.EndpointRelationService = endpointRelationService
edgeStackService, err := edgestack.NewService(store.connection, endpointRelationService.InvalidateEdgeCacheForEdgeStack)
edgeStackService, err := edgestack.NewService(store.connection, func(tx portainer.Transaction, ID portainer.EdgeStackID) {
endpointRelationService.Tx(tx).InvalidateEdgeCacheForEdgeStack(ID)
})
if err != nil {
return err
}

View File

@@ -610,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.25.0",
"KubectlShellImage": "portainer/kubectl-shell:2.27.0-rc1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.25.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.27.0-rc1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View File

@@ -64,9 +64,14 @@ type (
DeployerOptionsPayload struct {
// Prune is a flag indicating if the agent must prune the containers or not when creating/updating an edge stack
// This flag drives docker compose `--remove-orphans` and docker stack `--prune` options
// This flag drives `docker compose up --remove-orphans` and `docker stack up --prune` options
// Used only for EE
Prune bool
// RemoveVolumes is a flag indicating if the agent must remove the named volumes declared
// in the compose file and anonymouse volumes attached to containers
// This flag drives `docker compose down --volumes` option
// Used only for EE
RemoveVolumes bool
}
// RegistryCredentials holds the credentials for a Docker registry.

View File

@@ -44,13 +44,13 @@ func (service *Service) executeDeviceAction(configuration portainer.OpenAMTConfi
}
func parseAction(actionRaw string) (portainer.PowerState, error) {
switch strings.ToLower(actionRaw) {
case "power on":
if strings.EqualFold(actionRaw, "power on") {
return powerOnState, nil
case "power off":
} else if strings.EqualFold(actionRaw, "power off") {
return powerOffState, nil
case "restart":
} else if strings.EqualFold(actionRaw, "restart") {
return restartState, nil
}
return 0, fmt.Errorf("unsupported device action %s", actionRaw)
}

View File

@@ -13,6 +13,12 @@ import (
"github.com/urfave/negroni"
)
const csrfSkipHeader = "X-CSRF-Token-Skip"
func SkipCSRFToken(w http.ResponseWriter) {
w.Header().Set(csrfSkipHeader, "1")
}
func WithProtect(handler http.Handler) (http.Handler, error) {
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
@@ -42,10 +48,14 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
sw := negroni.NewResponseWriter(w)
sw.Before(func(sw negroni.ResponseWriter) {
statusCode := sw.Status()
if statusCode >= 200 && statusCode < 300 {
csrfToken := gorillacsrf.Token(r)
sw.Header().Set("X-CSRF-Token", csrfToken)
if len(sw.Header().Get(csrfSkipHeader)) > 0 {
sw.Header().Del(csrfSkipHeader)
return
}
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r))
}
})

View File

@@ -3,6 +3,7 @@ package edgestacks
import (
"errors"
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -52,10 +53,14 @@ func (handler *Handler) deleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", err)
}
err = handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups)
if err != nil {
if err := handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups); err != nil {
return httperror.InternalServerError("Unable to delete edge stack", err)
}
stackFolder := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(edgeStack.ID)))
if err := handler.FileService.RemoveDirectory(stackFolder); err != nil {
return httperror.InternalServerError("Unable to remove edge stack project folder", err)
}
return nil
}

View File

@@ -1,12 +1,14 @@
package edgestacks
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
"github.com/segmentio/encoding/json"
)
@@ -101,3 +103,52 @@ func TestDeleteInvalidEdgeStack(t *testing.T) {
})
}
}
func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
handler, rawAPIKey := setupHandler(t)
edgeGroup := createEdgeGroup(t, handler.DataStore)
payload := edgeStackFromStringPayload{
Name: "test-stack",
DeploymentType: portainer.EdgeStackDeploymentCompose,
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
StackFileContent: "version: '3.7'\nservices:\n test:\n image: test",
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
t.Fatal("error encoding payload:", err)
}
// Create
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf)
if err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code)
}
assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
// Delete
if req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil); err != nil {
t.Fatal("request error:", err)
}
req.Header.Add("x-api-key", rawAPIKey)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code)
}
assert.NoDirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
}

View File

@@ -34,7 +34,7 @@ func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *h
stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
if err != nil {
return handler.handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
}
fileName := stack.EntryPoint

View File

@@ -30,7 +30,7 @@ func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request)
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
if err != nil {
return handler.handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
}
return response.JSON(w, edgeStack)

View File

@@ -63,7 +63,7 @@ func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Req
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, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
}
environmentStatus, ok := stack.Status[endpoint.ID]

View File

@@ -4,10 +4,11 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -20,6 +21,7 @@ type updateStatusPayload struct {
Status *portainer.EdgeStackStatusType
EndpointID portainer.EndpointID
Time int64
Version int
}
func (payload *updateStatusPayload) Validate(r *http.Request) error {
@@ -67,11 +69,21 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return httperror.BadRequest("Invalid request payload", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, payload.EndpointID))
}
var stack *portainer.EdgeStack
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stack, err = handler.updateEdgeStackStatus(tx, r, portainer.EdgeStackID(stackID), payload)
return err
}); err != nil {
endpoint, err := handler.DataStore.Endpoint().Endpoint(payload.EndpointID)
if err != nil {
return handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
}
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
return handler.updateEdgeStackStatus(stack, endpoint, r, stack.ID, payload)
}
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
if err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
@@ -80,32 +92,16 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Unexpected error", err)
}
if ok, _ := strconv.ParseBool(r.Header.Get("X-Portainer-No-Body")); ok {
return nil
}
return response.JSON(w, stack)
}
func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
// skip error because agent tries to report on deleted stack
log.Debug().
Err(err).
Int("stackID", int(stackID)).
Int("status", int(*payload.Status)).
Msg("Unable to find a stack inside the database, skipping error")
return nil, nil
}
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w. Environment ID: %d", err, payload.EndpointID)
}
endpoint, err := tx.Endpoint().Endpoint(payload.EndpointID)
if err != nil {
return nil, handler.handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return nil, httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, endpoint *portainer.Endpoint, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
if payload.Version > 0 && payload.Version < stack.Version {
return stack, nil
}
status := *payload.Status
@@ -123,10 +119,6 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
if err := tx.EdgeStack().UpdateEdgeStack(stackID, stack); err != nil {
return nil, handler.handlerDBErr(fmt.Errorf("unable to update Edge stack to the database: %w. Environment name: %s", err, endpoint.Name), "unable to update Edge stack")
}
return stack, nil
}
@@ -145,7 +137,11 @@ func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeSt
}
}
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
if containsStatus := slices.ContainsFunc(environmentStatus.Status, func(e portainer.EdgeStackDeploymentStatus) bool {
return e.Type == deploymentStatus.Type
}); !containsStatus {
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
}
stack.Status[environmentId] = environmentStatus
}

View File

@@ -0,0 +1,150 @@
package edgestacks
import (
"errors"
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
type statusRequest struct {
respCh chan statusResponse
stackID portainer.EdgeStackID
updateFn statusUpdateFn
}
type statusResponse struct {
Stack *portainer.EdgeStack
Error error
}
type statusUpdateFn func(*portainer.EdgeStack) (*portainer.EdgeStack, error)
type EdgeStackStatusUpdateCoordinator struct {
updateCh chan statusRequest
dataStore dataservices.DataStore
}
var errAnotherStackUpdateInProgress = errors.New("another stack update is in progress")
func NewEdgeStackStatusUpdateCoordinator(dataStore dataservices.DataStore) *EdgeStackStatusUpdateCoordinator {
return &EdgeStackStatusUpdateCoordinator{
updateCh: make(chan statusRequest),
dataStore: dataStore,
}
}
func (c *EdgeStackStatusUpdateCoordinator) Start() {
for {
c.loop()
}
}
func (c *EdgeStackStatusUpdateCoordinator) loop() {
u := <-c.updateCh
respChs := []chan statusResponse{u.respCh}
var stack *portainer.EdgeStack
err := c.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
// 1. Load the edge stack
var err error
stack, err = loadEdgeStack(tx, u.stackID)
if err != nil {
return err
}
// 2. Mutate the edge stack opportunistically until there are no more pending updates
for {
stack, err = u.updateFn(stack)
if err != nil {
return err
}
if m, ok := c.getNextUpdate(stack.ID); ok {
u = m
} else {
break
}
respChs = append(respChs, u.respCh)
}
// 3. Save the changes back to the database
if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil {
return handlerDBErr(fmt.Errorf("unable to update Edge stack: %w.", err), "Unable to persist the stack changes inside the database")
}
return nil
})
// 4. Send back the responses
for _, ch := range respChs {
ch <- statusResponse{Stack: stack, Error: err}
}
}
func loadEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
// Skip the error when the agent tries to update the status on a deleted stack
log.Debug().
Err(err).
Int("stackID", int(stackID)).
Msg("Unable to find a stack inside the database, skipping error")
return nil, nil
}
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w.", err)
}
return stack, nil
}
func (c *EdgeStackStatusUpdateCoordinator) getNextUpdate(stackID portainer.EdgeStackID) (statusRequest, bool) {
for {
select {
case u := <-c.updateCh:
// Discard the update and let the agent retry
if u.stackID != stackID {
u.respCh <- statusResponse{Error: errAnotherStackUpdateInProgress}
continue
}
return u, true
default:
return statusRequest{}, false
}
}
}
func (c *EdgeStackStatusUpdateCoordinator) UpdateStatus(r *http.Request, stackID portainer.EdgeStackID, updateFn statusUpdateFn) (*portainer.EdgeStack, error) {
respCh := make(chan statusResponse)
defer close(respCh)
msg := statusRequest{
respCh: respCh,
stackID: stackID,
updateFn: updateFn,
}
select {
case c.updateCh <- msg:
r := <-respCh
return r.Stack, r.Error
case <-r.Context().Done():
return nil, r.Context().Err()
}
}

View File

@@ -51,10 +51,14 @@ func setupHandler(t *testing.T) (*Handler, string) {
t.Fatal(err)
}
coord := NewEdgeStackStatusUpdateCoordinator(store)
go coord.Start()
handler := NewHandler(
security.NewRequestBouncer(store, jwtService, apiKeyService),
store,
edgestacks.NewService(store),
coord,
)
handler.FileService = fs
@@ -144,3 +148,15 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
return edgeStack
}
func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeGroup {
edgeGroup := portainer.EdgeGroup{
ID: 1,
Name: "EdgeGroup 1",
}
if err := store.EdgeGroup().Create(&edgeGroup); err != nil {
t.Fatal(err)
}
return edgeGroup
}

View File

@@ -80,7 +80,7 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, payload updateEdgeStackPayload) (*portainer.EdgeStack, error) {
stack, err := tx.EdgeStack().EdgeStack(stackID)
if err != nil {
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
}
relationConfig, err := edge.FetchEndpointRelationsConfig(tx)

View File

@@ -22,15 +22,17 @@ type Handler struct {
GitService portainer.GitService
edgeStacksService *edgestackservice.Service
KubernetesDeployer portainer.KubernetesDeployer
stackCoordinator *EdgeStackStatusUpdateCoordinator
}
// NewHandler creates a handler to manage environment(endpoint) group operations.
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service, stackCoordinator *EdgeStackStatusUpdateCoordinator) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
DataStore: dataStore,
edgeStacksService: edgeStacksService,
stackCoordinator: stackCoordinator,
}
h.Handle("/edge_stacks/create/{method}",
@@ -58,10 +60,10 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
return h
}
func (handler *Handler) handlerDBErr(err error, msg string) *httperror.HandlerError {
func handlerDBErr(err error, msg string) *httperror.HandlerError {
httpErr := httperror.InternalServerError(msg, err)
if handler.DataStore.IsErrObjectNotFound(err) {
if dataservices.IsErrObjectNotFound(err) {
httpErr.StatusCode = http.StatusNotFound
}

View File

@@ -1,8 +1,10 @@
package endpointedge
import (
"errors"
"fmt"
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/edge"
@@ -13,8 +15,12 @@ import (
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"golang.org/x/sync/singleflight"
)
var edgeStackSingleFlightGroup = singleflight.Group{}
// @summary Inspect an Edge Stack for an Environment(Endpoint)
// @description **Access policy**: public
// @tags edge, endpoints, edge_stacks
@@ -42,13 +48,26 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment name: %s", err, endpoint.Name))
}
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
} else if err != nil {
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
s, err, _ := edgeStackSingleFlightGroup.Do(strconv.Itoa(edgeStackID), func() (any, error) {
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
}
return edgeStack, err
})
if err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
}
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment name: %s", err, endpoint.Name))
}
// WARNING: this variable must not be mutated
edgeStack := s.(*portainer.EdgeStack)
fileName := edgeStack.EntryPoint
if endpointutils.IsDockerEndpoint(endpoint) {
if fileName == "" {

View File

@@ -83,7 +83,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.25.0
// @version 2.26.0
// @description.markdown api-description.md
// @termsOfService

View File

@@ -0,0 +1,78 @@
package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
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"
)
// @id GetKubernetesCronJobs
// @summary Get a list of kubernetes Cron Jobs
// @description Get a list of kubernetes Cron Jobs that the user has access to.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @success 200 {array} models.K8sCronJob "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 list of Cron Jobs."
// @router /kubernetes/{id}/cron_jobs [get]
func (handler *Handler) getAllKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to prepare kube client")
return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr)
}
cronJobs, err := cli.GetCronJobs("")
if err != nil {
log.Error().Err(err).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to fetch Cron Jobs across all namespaces")
return httperror.InternalServerError("unable to fetch Cron Jobs. Error: ", err)
}
return response.JSON(w, cronJobs)
}
// @id DeleteCronJobs
// @summary Delete Cron Jobs
// @description Delete the provided list of Cron Jobs.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sCronJobDeleteRequests true "A map where the key is the namespace and the value is an array of Cron Jobs to delete"
// @success 204 "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 unable to find a specific service account."
// @failure 500 "Server error occurred while attempting to delete Cron Jobs."
// @router /kubernetes/{id}/cron_jobs/delete [POST]
func (handler *Handler) deleteKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sCronJobDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := handler.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
err = cli.DeleteCronJobs(payload)
if err != nil {
return httperror.InternalServerError("Unable to delete Cron Jobs", err)
}
return response.Empty(w)
}

View File

@@ -55,6 +55,10 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/applications/count", httperror.LoggerHandler(h.getAllKubernetesApplicationsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet)
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("/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)
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)

View File

@@ -0,0 +1,85 @@
package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
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"
)
// @id GetKubernetesJobs
// @summary Get a list of kubernetes Jobs
// @description Get a list of kubernetes Jobs that the user has access to.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param includeCronJobChildren query bool false "Whether to include Jobs that have a cronjob owner"
// @success 200 {array} models.K8sJob "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 list of Jobs."
// @router /kubernetes/{id}/jobs [get]
func (handler *Handler) getAllKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
includeCronJobChildren, err := request.RetrieveBooleanQueryParameter(r, "includeCronJobChildren", true)
if err != nil {
log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Invalid query parameter includeCronJobChildren")
return httperror.BadRequest("an error occurred during the GetAllKubernetesJobs operation, invalid query parameter includeCronJobChildren. Error: ", err)
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetAllKubernetesJobs").Msg("Unable to prepare kube client")
return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr)
}
jobs, err := cli.GetJobs("", includeCronJobChildren)
if err != nil {
log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Unable to fetch Jobs across all namespaces")
return httperror.InternalServerError("unable to fetch Jobs. Error: ", err)
}
return response.JSON(w, jobs)
}
// @id DeleteJobs
// @summary Delete Jobs
// @description Delete the provided list of Jobs.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sJobDeleteRequests true "A map where the key is the namespace and the value is an array of Jobs to delete"
// @success 204 "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 unable to find a specific service account."
// @failure 500 "Server error occurred while attempting to delete Jobs."
// @router /kubernetes/{id}/jobs/delete [POST]
func (handler *Handler) deleteKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sJobDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := handler.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
err = cli.DeleteJobs(payload)
if err != nil {
return httperror.InternalServerError("Unable to delete Jobs", err)
}
return response.Empty(w)
}

View File

@@ -36,5 +36,9 @@ func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *ht
return httperror.InternalServerError("Unable to retrieve registries from the database", err)
}
for idx := range registries {
hideFields(&registries[idx], false)
}
return response.JSON(w, registries)
}

View File

@@ -3,6 +3,7 @@ package system
import (
"net/http"
portainer "github.com/portainer/portainer/api"
statusutil "github.com/portainer/portainer/api/internal/nodes"
"github.com/portainer/portainer/api/internal/snapshot"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -31,14 +32,15 @@ func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Failed to get environment list", err)
}
for i := range endpoints {
err = snapshot.FillSnapshotData(handler.dataStore, &endpoints[i])
if err != nil {
var nodes int
for _, endpoint := range endpoints {
if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint); err != nil {
return httperror.InternalServerError("Unable to add snapshot data", err)
}
}
nodes := statusutil.NodesCount(endpoints)
nodes += statusutil.NodesCount([]portainer.Endpoint{endpoint})
}
return response.JSON(w, &nodesCountResponse{Nodes: nodes})
}

View File

@@ -0,0 +1,36 @@
package kubernetes
import (
"errors"
"net/http"
)
type K8sCronJob struct {
Id string `json:"Id"`
Name string `json:"Name"`
Namespace string `json:"Namespace"`
Command string `json:"Command"`
Schedule string `json:"Schedule"`
Timezone string `json:"Timezone"`
Suspend bool `json:"Suspend"`
Jobs []K8sJob `json:"Jobs"`
IsSystem bool `json:"IsSystem"`
}
type (
K8sCronJobDeleteRequests map[string][]string
)
func (r K8sCronJobDeleteRequests) Validate(request *http.Request) error {
if len(r) == 0 {
return errors.New("missing deletion request list in payload")
}
for ns := range r {
if len(ns) == 0 {
return errors.New("deletion given with empty namespace")
}
}
return nil
}

View File

@@ -0,0 +1,44 @@
package kubernetes
import (
"errors"
"net/http"
corev1 "k8s.io/api/core/v1"
)
// K8sJob struct
type K8sJob struct {
ID string `json:"Id"`
Namespace string `json:"Namespace"`
Name string `json:"Name"`
PodName string `json:"PodName"`
Container corev1.Container `json:"Container,omitempty"`
Command string `json:"Command,omitempty"`
BackoffLimit int32 `json:"BackoffLimit,omitempty"`
Completions int32 `json:"Completions,omitempty"`
StartTime string `json:"StartTime"`
FinishTime string `json:"FinishTime"`
Duration string `json:"Duration"`
Status string `json:"Status"`
FailedReason string `json:"FailedReason"`
IsSystem bool `json:"IsSystem"`
}
type (
K8sJobDeleteRequests map[string][]string
)
func (r K8sJobDeleteRequests) Validate(request *http.Request) error {
if len(r) == 0 {
return errors.New("missing deletion request list in payload")
}
for ns := range r {
if len(ns) == 0 {
return errors.New("deletion given with empty namespace")
}
}
return nil
}

View File

@@ -11,6 +11,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
@@ -37,6 +38,8 @@ type (
dockerClientFactory *dockerclient.ClientFactory
gitService portainer.GitService
snapshotService portainer.SnapshotService
dockerID string
mu sync.Mutex
}
// TransportParameters is used to create a new Transport
@@ -679,9 +682,7 @@ func (transport *Transport) executeGenericResourceDeletionOperation(request *htt
}
if resourceControl != nil {
if err := transport.dataStore.ResourceControl().Delete(resourceControl.ID); err != nil {
return response, err
}
err = transport.dataStore.ResourceControl().Delete(resourceControl.ID)
}
return response, err

View File

@@ -14,7 +14,6 @@ import (
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/docker/docker/client"
"github.com/rs/zerolog/log"
)
const volumeObjectIdentifier = "ResourceID"
@@ -50,15 +49,6 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
volumeData := responseObject["Volumes"].([]any)
if transport.snapshotService != nil {
// Filling snapshot data can improve the performance of getVolumeResourceID
if err = transport.snapshotService.FillSnapshotData(transport.endpoint); err != nil {
log.Info().Err(err).
Int("endpoint id", int(transport.endpoint.ID)).
Msg("snapshot is not filled into the endpoint.")
}
}
for _, volumeObject := range volumeData {
volume := volumeObject.(map[string]any)
@@ -147,7 +137,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
}
defer cli.Close()
if _, err = cli.VolumeInspect(context.Background(), volumeID); err == nil {
if _, err := cli.VolumeInspect(context.Background(), volumeID); err == nil {
return &http.Response{
StatusCode: http.StatusConflict,
}, errors.New("a volume with the same name already exists")
@@ -222,14 +212,27 @@ func (transport *Transport) getVolumeResourceID(volumeName string) (string, erro
}
func (transport *Transport) getDockerID() (string, error) {
if len(transport.endpoint.Snapshots) > 0 {
dockerID, err := snapshot.FetchDockerID(transport.endpoint.Snapshots[0])
// ignore err - in case of error, just generate not from snapshot
if err == nil {
return dockerID, nil
transport.mu.Lock()
defer transport.mu.Unlock()
// Local cache
if transport.dockerID != "" {
return transport.dockerID, nil
}
// Snapshot cache
if transport.snapshotService != nil {
endpoint := portainer.Endpoint{ID: transport.endpoint.ID}
if err := transport.snapshotService.FillSnapshotData(&endpoint); err == nil && len(endpoint.Snapshots) > 0 {
if dockerID, err := snapshot.FetchDockerID(endpoint.Snapshots[0]); err == nil {
transport.dockerID = dockerID
return dockerID, nil
}
}
}
// Remote value
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
if err != nil {
return "", err
@@ -242,8 +245,11 @@ func (transport *Transport) getDockerID() (string, error) {
}
if info.Swarm.Cluster != nil {
return info.Swarm.Cluster.ID, nil
transport.dockerID = info.Swarm.Cluster.ID
return transport.dockerID, nil
}
return info.ID, nil
transport.dockerID = info.ID
return transport.dockerID, nil
}

View File

@@ -161,7 +161,10 @@ func (server *Server) Start() error {
edgeJobsHandler.FileService = server.FileService
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService)
edgeStackCoordinator := edgestacks.NewEdgeStackStatusUpdateCoordinator(server.DataStore)
go edgeStackCoordinator.Start()
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService, edgeStackCoordinator)
edgeStacksHandler.FileService = server.FileService
edgeStacksHandler.GitService = server.GitService
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer

View File

@@ -9,7 +9,6 @@ func NewStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, rel
status := map[portainer.EndpointID]portainer.EdgeStackStatus{}
for _, environmentID := range relatedEnvironmentIDs {
newEnvStatus := portainer.EdgeStackStatus{
Status: []portainer.EdgeStackDeploymentStatus{},
EndpointID: environmentID,

View File

@@ -16,7 +16,7 @@ import (
"github.com/rs/zerolog/log"
)
// Service repesents a service to manage environment(endpoint) snapshots.
// Service represents a service to manage environment(endpoint) snapshots.
// It provides an interface to start background snapshots as well as
// specific Docker/Kubernetes environment(endpoint) snapshot methods.
type Service struct {
@@ -174,30 +174,6 @@ func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint) error {
return FillSnapshotData(service.dataStore, endpoint)
}
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
snapshot, err := tx.Snapshot().Read(endpoint.ID)
if tx.IsErrObjectNotFound(err) {
endpoint.Snapshots = []portainer.DockerSnapshot{}
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
return nil
}
if err != nil {
return err
}
if snapshot.Docker != nil {
endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker}
}
if snapshot.Kubernetes != nil {
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot.Kubernetes}
}
return nil
}
func (service *Service) snapshotKubernetesEndpoint(endpoint *portainer.Endpoint) error {
kubernetesSnapshot, err := service.kubernetesSnapshotter.CreateSnapshot(endpoint)
if err != nil {
@@ -285,11 +261,16 @@ func (service *Service) snapshotEndpoints() error {
snapshotError := service.SnapshotEndpoint(&endpoint)
service.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := service.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
updateEndpointStatus(tx, &endpoint, snapshotError, service.pendingActionsService)
return nil
})
}); err != nil {
log.Error().
Err(err).
Int("endpoint_id", int(endpoint.ID)).
Msg("unable to update environment status")
}
}
return nil
@@ -340,12 +321,31 @@ func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) {
return info.ID, nil
}
swarmInfo := info.Swarm
if swarmInfo.Cluster == nil {
if info.Swarm.Cluster == nil {
return "", errors.New("swarm environment is missing cluster info snapshot")
}
clusterInfo := swarmInfo.Cluster
return clusterInfo.ID, nil
return info.Swarm.Cluster.ID, nil
}
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
snapshot, err := tx.Snapshot().Read(endpoint.ID)
if tx.IsErrObjectNotFound(err) {
endpoint.Snapshots = []portainer.DockerSnapshot{}
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
return nil
} else if err != nil {
return err
}
if snapshot.Docker != nil {
endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker}
}
if snapshot.Kubernetes != nil {
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot.Kubernetes}
}
return nil
}

View File

@@ -64,8 +64,7 @@ func (service *Service) Init(host, certPath, keyPath string) error {
// path not supplied and certificates doesn't exist - generate self-signed
certPath, keyPath = service.fileService.GetDefaultSSLCertsPath()
err = generateSelfSignedCertificates(host, certPath, keyPath)
if err != nil {
if err := generateSelfSignedCertificates(host, certPath, keyPath); err != nil {
return errors.Wrap(err, "failed generating self signed certs")
}
@@ -98,8 +97,7 @@ func (service *Service) SetCertificates(certData, keyData []byte) error {
return errors.New("missing certificate files")
}
_, err := tls.X509KeyPair(certData, keyData)
if err != nil {
if _, err := tls.X509KeyPair(certData, keyData); err != nil {
return err
}
@@ -108,8 +106,7 @@ func (service *Service) SetCertificates(certData, keyData []byte) error {
return err
}
err = service.cacheInfo(certPath, keyPath, false)
if err != nil {
if err := service.cacheInfo(certPath, keyPath, false); err != nil {
return err
}
@@ -130,8 +127,7 @@ func (service *Service) SetHTTPEnabled(httpEnabled bool) error {
settings.HTTPEnabled = httpEnabled
err = service.dataStore.SSLSettings().UpdateSettings(settings)
if err != nil {
if err := service.dataStore.SSLSettings().UpdateSettings(settings); err != nil {
return err
}
@@ -152,8 +148,7 @@ func (service *Service) cacheCertificate(certPath, keyPath string) error {
}
func (service *Service) cacheInfo(certPath string, keyPath string, selfSigned bool) error {
err := service.cacheCertificate(certPath, keyPath)
if err != nil {
if err := service.cacheCertificate(certPath, keyPath); err != nil {
return err
}

View File

@@ -0,0 +1,123 @@
package cli
import (
"context"
"strings"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
batchv1 "k8s.io/api/batch/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetCronJobs returns all cronjobs in the given namespace
// If the user is a kube admin, it returns all cronjobs in the namespace
// Otherwise, it returns only the cronjobs in the non-admin namespaces
func (kcl *KubeClient) GetCronJobs(namespace string) ([]models.K8sCronJob, error) {
if kcl.IsKubeAdmin {
return kcl.fetchCronJobs(namespace)
}
return kcl.fetchCronJobsForNonAdmin(namespace)
}
// fetchCronJobsForNonAdmin returns all cronjobs in the given namespace
// It returns only the cronjobs in the non-admin namespaces
func (kcl *KubeClient) fetchCronJobsForNonAdmin(namespace string) ([]models.K8sCronJob, error) {
cronJobs, err := kcl.fetchCronJobs(namespace)
if err != nil {
return nil, err
}
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
results := make([]models.K8sCronJob, 0)
for _, cronJob := range cronJobs {
if _, ok := nonAdminNamespaceSet[cronJob.Namespace]; ok {
results = append(results, cronJob)
}
}
return results, nil
}
// fetchCronJobs returns all cronjobs in the given namespace
// It returns all cronjobs in the namespace
func (kcl *KubeClient) fetchCronJobs(namespace string) ([]models.K8sCronJob, error) {
cronJobs, err := kcl.cli.BatchV1().CronJobs(namespace).List(context.TODO(), metav1.ListOptions{})
if err != nil {
return nil, err
}
jobs, err := kcl.cli.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{})
if err != nil {
return nil, err
}
results := make([]models.K8sCronJob, 0)
for _, cronJob := range cronJobs.Items {
results = append(results, kcl.parseCronJob(cronJob, jobs))
}
return results, nil
}
// parseCronJob converts a batchv1.CronJob object to a models.K8sCronJob object.
func (kcl *KubeClient) parseCronJob(cronJob batchv1.CronJob, jobsList *batchv1.JobList) models.K8sCronJob {
jobs, err := kcl.getCronJobExecutions(cronJob.Name, jobsList)
if err != nil {
return models.K8sCronJob{}
}
timezone := "<none>"
if cronJob.Spec.TimeZone != nil {
timezone = *cronJob.Spec.TimeZone
}
suspend := false
if cronJob.Spec.Suspend != nil {
suspend = *cronJob.Spec.Suspend
}
return models.K8sCronJob{
Id: string(cronJob.UID),
Name: cronJob.Name,
Namespace: cronJob.Namespace,
Command: strings.Join(cronJob.Spec.JobTemplate.Spec.Template.Spec.Containers[0].Command, " "),
Schedule: cronJob.Spec.Schedule,
Timezone: timezone,
Suspend: suspend,
Jobs: jobs,
IsSystem: kcl.isSystemCronJob(cronJob.Namespace),
}
}
func (kcl *KubeClient) isSystemCronJob(namespace string) bool {
return kcl.isSystemNamespace(namespace)
}
// DeleteCronJobs deletes the provided list of cronjobs in its namespace
// it returns an error if any of the cronjobs are not found or if there is an error deleting the cronjobs
func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error {
var errors []error
for namespace := range payload {
for _, cronJobName := range payload[namespace] {
client := kcl.cli.BatchV1().CronJobs(namespace)
_, err := client.Get(context.Background(), cronJobName, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
}
errors = append(errors, err)
}
if err := client.Delete(context.Background(), cronJobName, metav1.DeleteOptions{}); err != nil {
errors = append(errors, err)
}
}
}
return errorlist.Combine(errors)
}

View File

@@ -0,0 +1,66 @@
package cli
import (
"context"
"testing"
models "github.com/portainer/portainer/api/http/models/kubernetes"
batchv1 "k8s.io/api/batch/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
)
// TestFetchCronJobs tests the fetchCronJobs method for both admin and non-admin clients
// It creates a fake Kubernetes client and passes it to the fetchCronJobs method
// It then logs the fetched Cron Jobs
// non-admin client will have access to the default namespace only
func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
t.Run("admin client can fetch Cron Jobs from all namespaces", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.IsKubeAdmin = true
cronJobs, err := kcl.GetCronJobs("")
if err != nil {
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
}
t.Logf("Fetched Cron Jobs: %v", cronJobs)
})
t.Run("non-admin client can fetch Cron Jobs from the default namespace only", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.IsKubeAdmin = false
kcl.NonAdminNamespaces = []string{"default"}
cronJobs, err := kcl.GetCronJobs("")
if err != nil {
t.Fatalf("Failed to fetch Cron Jobs: %v", err)
}
t.Logf("Fetched Cron Jobs: %v", cronJobs)
})
t.Run("delete Cron Jobs", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
_, err := kcl.cli.BatchV1().CronJobs("default").Create(context.Background(), &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{Name: "test-cronjob"},
}, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create cron job: %v", err)
}
err = kcl.DeleteCronJobs(models.K8sCronJobDeleteRequests{
"default": []string{"test-cronjob"},
})
if err != nil {
t.Fatalf("Failed to delete Cron Jobs: %v", err)
}
t.Logf("Deleted Cron Jobs")
})
}

227
api/kubernetes/cli/job.go Normal file
View File

@@ -0,0 +1,227 @@
package cli
import (
"context"
"fmt"
"sort"
"strings"
"time"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
"github.com/rs/zerolog/log"
batchv1 "k8s.io/api/batch/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetJobs returns all jobs in the given namespace
// If the user is a kube admin, it returns all jobs in the namespace
// Otherwise, it returns only the jobs in the non-admin namespaces
func (kcl *KubeClient) GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) {
if kcl.IsKubeAdmin {
return kcl.fetchJobs(namespace, includeCronJobChildren)
}
return kcl.fetchJobsForNonAdmin(namespace, includeCronJobChildren)
}
// fetchJobsForNonAdmin returns all jobs in the given namespace
// It returns only the jobs in the non-admin namespaces
func (kcl *KubeClient) fetchJobsForNonAdmin(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) {
jobs, err := kcl.fetchJobs(namespace, includeCronJobChildren)
if err != nil {
return nil, err
}
nonAdminNamespaceSet := kcl.buildNonAdminNamespacesMap()
results := make([]models.K8sJob, 0)
for _, job := range jobs {
if _, ok := nonAdminNamespaceSet[job.Namespace]; ok {
results = append(results, job)
}
}
return results, nil
}
// fetchJobs returns all jobs in the given namespace
// It returns all jobs in the namespace
func (kcl *KubeClient) fetchJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) {
jobs, err := kcl.cli.BatchV1().Jobs(namespace).List(context.TODO(), metav1.ListOptions{})
if err != nil {
return nil, err
}
results := make([]models.K8sJob, 0)
for _, job := range jobs.Items {
if !includeCronJobChildren && checkCronJobOwner(job) {
continue
}
results = append(results, kcl.parseJob(job))
}
return results, nil
}
// checkCronJobOwner checks if the job has a cronjob owner
// it returns true if the job has a cronjob owner
// otherwise, it returns false
func checkCronJobOwner(job batchv1.Job) bool {
for _, owner := range job.OwnerReferences {
if owner.Kind == "CronJob" {
return true
}
}
return false
}
// parseJob converts a batchv1.Job object to a models.K8sJob object.
func (kcl *KubeClient) parseJob(job batchv1.Job) models.K8sJob {
times := parseJobTimes(job)
status, failedReason := determineJobStatus(job)
podName := getJobPodName(kcl, job)
return models.K8sJob{
ID: string(job.UID),
Namespace: job.Namespace,
Name: job.Name,
PodName: podName,
Command: strings.Join(job.Spec.Template.Spec.Containers[0].Command, " "),
Container: job.Spec.Template.Spec.Containers[0],
BackoffLimit: *job.Spec.BackoffLimit,
Completions: *job.Spec.Completions,
StartTime: times.start,
FinishTime: times.finish,
Duration: times.duration,
Status: status,
FailedReason: failedReason,
IsSystem: kcl.isSystemJob(job.Namespace),
}
}
func (kcl *KubeClient) isSystemJob(namespace string) bool {
return kcl.isSystemNamespace(namespace)
}
type jobTimes struct {
start string
finish string
duration string
}
func parseJobTimes(job batchv1.Job) jobTimes {
times := jobTimes{
start: "N/A",
finish: "N/A",
duration: "N/A",
}
if st := job.Status.StartTime; st != nil {
times.start = st.Time.Format(time.RFC3339)
times.duration = time.Since(st.Time).Truncate(time.Minute).String()
if ct := job.Status.CompletionTime; ct != nil {
times.finish = ct.Time.Format(time.RFC3339)
times.duration = ct.Time.Sub(st.Time).String()
}
}
return times
}
func determineJobStatus(job batchv1.Job) (status, failedReason string) {
failedReason = "N/A"
switch {
case job.Status.Failed > 0:
return "Failed", getLatestJobCondition(job.Status.Conditions)
case job.Status.Succeeded > 0:
return "Succeeded", failedReason
case job.Status.Active == 0:
return "Completed", failedReason
default:
return "Running", failedReason
}
}
func getJobPodName(kcl *KubeClient, job batchv1.Job) string {
pod, err := kcl.getLatestJobPod(job.Namespace, job.Name)
if err != nil {
log.Warn().Err(err).
Str("job", job.Name).
Str("namespace", job.Namespace).
Msg("Failed to get latest job pod")
return ""
}
if pod != nil {
return pod.Name
}
return ""
}
// getCronJobExecutions returns the jobs for a given cronjob
// it returns the jobs for the cronjob
func (kcl *KubeClient) getCronJobExecutions(cronJobName string, jobs *batchv1.JobList) ([]models.K8sJob, error) {
maxItems := 5
results := make([]models.K8sJob, 0)
for _, job := range jobs.Items {
for _, owner := range job.OwnerReferences {
if owner.Kind == "CronJob" && owner.Name == cronJobName {
results = append(results, kcl.parseJob(job))
if len(results) >= maxItems {
return results, nil
}
}
}
}
return results, nil
}
// DeleteJobs deletes the provided list of jobs
// it returns an error if any of the jobs are not found or if there is an error deleting the jobs
func (kcl *KubeClient) DeleteJobs(payload models.K8sJobDeleteRequests) error {
var errors []error
for namespace := range payload {
for _, jobName := range payload[namespace] {
client := kcl.cli.BatchV1().Jobs(namespace)
_, err := client.Get(context.Background(), jobName, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
}
errors = append(errors, err)
}
if err := client.Delete(context.Background(), jobName, metav1.DeleteOptions{}); err != nil {
errors = append(errors, err)
}
}
}
return errorlist.Combine(errors)
}
// getLatestJobCondition returns the latest condition of the job
// it returns the latest condition of the job
// this is only used for the failed reason
func getLatestJobCondition(conditions []batchv1.JobCondition) string {
if len(conditions) == 0 {
return "No conditions"
}
sort.Slice(conditions, func(i, j int) bool {
return conditions[i].LastTransitionTime.After(conditions[j].LastTransitionTime.Time)
})
latest := conditions[0]
return fmt.Sprintf("%s: %s", latest.Type, latest.Message)
}

View File

@@ -0,0 +1,64 @@
package cli
import (
"context"
"testing"
models "github.com/portainer/portainer/api/http/models/kubernetes"
batchv1 "k8s.io/api/batch/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kfake "k8s.io/client-go/kubernetes/fake"
)
// TestFetchJobs tests the fetchJobs method for both admin and non-admin clients
// It creates a fake Kubernetes client and passes it to the fetchJobs method
// It then logs the fetched jobs
// non-admin client will have access to the default namespace only
func (kcl *KubeClient) TestFetchJobs(t *testing.T) {
t.Run("admin client can fetch jobs from all namespaces", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.IsKubeAdmin = true
jobs, err := kcl.GetJobs("", false)
if err != nil {
t.Fatalf("Failed to fetch jobs: %v", err)
}
t.Logf("Fetched jobs: %v", jobs)
})
t.Run("non-admin client can fetch jobs from the default namespace only", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.IsKubeAdmin = false
kcl.NonAdminNamespaces = []string{"default"}
jobs, err := kcl.GetJobs("", false)
if err != nil {
t.Fatalf("Failed to fetch jobs: %v", err)
}
t.Logf("Fetched jobs: %v", jobs)
})
t.Run("delete jobs", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
_, err := kcl.cli.BatchV1().Jobs("default").Create(context.Background(), &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{Name: "test-job"},
}, metav1.CreateOptions{})
if err != nil {
t.Fatalf("Failed to create job: %v", err)
}
err = kcl.DeleteJobs(models.K8sJobDeleteRequests{
"default": []string{"test-job"},
})
if err != nil {
t.Fatalf("Failed to delete jobs: %v", err)
}
})
}

View File

@@ -275,3 +275,22 @@ func isPodUsingSecret(pod *corev1.Pod, secretName string) bool {
return false
}
// getLatestJobPod returns the pods that are owned by a job
// it returns an error if there is an error fetching the pods
func (kcl *KubeClient) getLatestJobPod(namespace string, jobName string) (*corev1.Pod, error) {
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{})
if err != nil {
return nil, err
}
for _, pod := range pods.Items {
for _, owner := range pod.OwnerReferences {
if owner.Kind == "Job" && owner.Name == jobName {
return &pod, nil
}
}
}
return nil, nil
}

View File

@@ -50,7 +50,7 @@ func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings
return "", err
}
maps.Copy(idToken, resource)
maps.Copy(resource, idToken)
username, err := GetUsername(resource, configuration.UserIdentifier)
if err != nil {

View File

@@ -5,10 +5,12 @@ import (
"fmt"
"slices"
"strings"
"sync"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/rs/zerolog/log"
)
@@ -21,38 +23,46 @@ type service struct {
dataStore dataservices.DataStore
environment *portainer.Endpoint
platform ContainerPlatform
mu sync.Mutex
}
func NewService(dataStore dataservices.DataStore) (Service, error) {
func NewService(dataStore dataservices.DataStore) (*service, error) {
return &service{dataStore: dataStore}, nil
}
return &service{
dataStore: dataStore,
}, nil
func (service *service) loadEnvAndPlatform() error {
if service.environment != nil {
return nil
}
environment, platform, err := guessLocalEnvironment(service.dataStore)
if err != nil {
return err
}
service.environment = environment
service.platform = platform
return nil
}
func (service *service) GetLocalEnvironment() (*portainer.Endpoint, error) {
if service.environment == nil {
environment, platform, err := guessLocalEnvironment(service.dataStore)
if err != nil {
return nil, err
}
service.mu.Lock()
defer service.mu.Unlock()
service.environment = environment
service.platform = platform
if err := service.loadEnvAndPlatform(); err != nil {
return nil, err
}
return service.environment, nil
}
func (service *service) GetPlatform() (ContainerPlatform, error) {
if service.environment == nil {
environment, platform, err := guessLocalEnvironment(service.dataStore)
if err != nil {
return "", err
}
service.mu.Lock()
defer service.mu.Unlock()
service.environment = environment
service.platform = platform
if err := service.loadEnvAndPlatform(); err != nil {
return "", err
}
return service.platform, nil
@@ -90,15 +100,16 @@ func guessLocalEnvironment(dataStore dataservices.DataStore) (*portainer.Endpoin
}
for _, endpoint := range endpoints {
if slices.Contains(endpointTypes, endpoint.Type) {
if platform != PlatformDocker {
return &endpoint, platform, nil
}
if !slices.Contains(endpointTypes, endpoint.Type) {
continue
}
dockerPlatform := checkDockerEnvTypeForUpgrade(&endpoint)
if dockerPlatform != "" {
return &endpoint, dockerPlatform, nil
}
if platform != PlatformDocker {
return &endpoint, platform, nil
}
if dockerPlatform := checkDockerEnvTypeForUpgrade(&endpoint); dockerPlatform != "" {
return &endpoint, dockerPlatform, nil
}
}

View File

@@ -370,6 +370,7 @@ type (
Error string
// EE only feature
RollbackTo *int
Version int `json:"Version,omitempty"`
}
// EdgeStackStatusType represents an edge stack status type
@@ -1395,6 +1396,13 @@ type (
Prune bool
}
ComposeDownOptions struct {
// RemoveVolumes will remove the named volumes declared in the compose file
// and anonymous volumes attached to the stack's containers
// Drives `docker compose down --volumes`
RemoveVolumes bool
}
ComposeRunOptions struct {
ComposeOptions
@@ -1628,9 +1636,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.25.0"
APIVersion = "2.27.0-rc1"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS"
APIVersionSupport = "LTS"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax

View File

@@ -581,6 +581,19 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
abstract: true,
};
const jobs = {
name: 'kubernetes.moreResources.jobs',
url: '/jobs?tab',
views: {
'content@': {
component: 'jobsView',
},
},
data: {
docs: '/user/kubernetes/more-resources/jobs',
},
};
const serviceAccounts = {
name: 'kubernetes.moreResources.serviceAccounts',
url: '/serviceAccounts',
@@ -661,6 +674,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
$stateRegistryProvider.register(ingressesEdit);
$stateRegistryProvider.register(moreResources);
$stateRegistryProvider.register(jobs);
$stateRegistryProvider.register(serviceAccounts);
$stateRegistryProvider.register(clusterRoles);
$stateRegistryProvider.register(roles);

View File

@@ -5,6 +5,7 @@ class KubernetesConfigurationConverter {
static secretToConfiguration(secret) {
const res = new KubernetesConfiguration();
res.Kind = KubernetesConfigurationKinds.SECRET;
res.kind = 'Secret';
res.Id = secret.Id;
res.Name = secret.Name;
res.Type = secret.Type;
@@ -19,8 +20,15 @@ class KubernetesConfigurationConverter {
res.IsRegistrySecret = secret.IsRegistrySecret;
res.SecretType = secret.SecretType;
if (secret.Annotations) {
const serviceAccountAnnotation = secret.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name');
res.ServiceAccountName = serviceAccountAnnotation ? serviceAccountAnnotation.value : undefined;
const serviceAccountKey = 'kubernetes.io/service-account.name';
if (typeof secret.Annotations === 'object') {
res.ServiceAccountName = secret.Annotations[serviceAccountKey];
} else if (Array.isArray(secret.Annotations)) {
const serviceAccountAnnotation = secret.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name');
res.ServiceAccountName = serviceAccountAnnotation ? serviceAccountAnnotation.value : undefined;
} else {
res.ServiceAccountName = undefined;
}
}
res.Labels = secret.Labels;
return res;
@@ -29,6 +37,7 @@ class KubernetesConfigurationConverter {
static configMapToConfiguration(configMap) {
const res = new KubernetesConfiguration();
res.Kind = KubernetesConfigurationKinds.CONFIGMAP;
res.kind = 'ConfigMap';
res.Id = configMap.Id;
res.Name = configMap.Name;
res.Namespace = configMap.Namespace;

View File

@@ -9,6 +9,7 @@ const _KubernetesConfigurationFormValues = Object.freeze({
Name: '',
ConfigurationOwner: '',
Kind: KubernetesConfigurationKinds.CONFIGMAP,
kind: 'ConfigMap',
Data: [],
DataYaml: '',
IsSimple: true,

View File

@@ -21,6 +21,7 @@ import { RolesView } from '@/react/kubernetes/more-resources/RolesView';
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';
export const viewsModule = angular
.module('portainer.kubernetes.react.views', [])
@@ -89,6 +90,10 @@ export const viewsModule = angular
'kubernetesConsoleView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ConsoleView))), [])
)
.component(
'jobsView',
r2a(withUIRouter(withReactQuery(withCurrentUser(JobsView))), [])
)
.component(
'serviceAccountsView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ServiceAccountsView))), [])

View File

@@ -22,29 +22,6 @@
</kubernetes-resource-reservation>
</form>
<!-- !resource-reservation -->
<!-- leader-status -->
<div ng-if="ctrl.systemEndpoints.length > 0">
<div class="col-sm-12 form-section-title"> Leader status </div>
<table class="table">
<tbody>
<tr class="text-muted">
<td style="border-top: none; width: 25%">Component</td>
<td style="border-top: none; width: 25%">Leader node</td>
</tr>
<tr ng-repeat="ep in ctrl.systemEndpoints">
<td style="width: 25%">
{{ ep.Name }}
</td>
<td style="width: 25%">
{{ ep.HolderIdentity }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- !leader-status -->
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -1,4 +1,4 @@
<page-header ng-if="ctrl.state.viewReady" title="'Create from file'" breadcrumbs="['Deploy Kubernetes resources']" reload="true"></page-header>
<page-header ng-if="ctrl.state.viewReady" title="'Create from code'" breadcrumbs="['Deploy Kubernetes resources']" reload="true"></page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>

View File

@@ -15,6 +15,10 @@ function SettingsAuthenticationController($q, $scope, $state, Notifications, Set
uploadInProgress: false,
actionInProgress: false,
availableUserSessionTimeoutOptions: [
{
key: '30 minutes',
value: '30m',
},
{
key: '1 hour',
value: '1h',

View File

@@ -10,8 +10,13 @@ import {
import { notifyError } from '@/portainer/services/notifications';
/**
* @deprecated use withGlobalError
* `onError` and other callbacks are not supported on react-query v5
* @deprecated for `useQuery` ONLY. Use `withGlobalError`.
*
* `onError` and other callbacks are not supported on `useQuery` in react-query v5
*
* Using `withError` is fine for mutations (`useMutation`)
*
* see https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose
*/
export function withError(fallbackMessage?: string, title = 'Failure') {
return {

View File

@@ -0,0 +1,52 @@
/* eslint-disable no-console */
import { intersection } from 'lodash';
import { useEffect, useRef } from 'react';
function logPropDifferences(
newProps: Record<string, unknown>,
lastProps: Record<string, unknown>,
verbose: boolean
) {
const allKeys = intersection(Object.keys(newProps), Object.keys(lastProps));
const changedKeys: string[] = [];
allKeys.forEach((key) => {
const newValue = newProps[key];
const lastValue = lastProps[key];
if (newValue !== lastValue) {
changedKeys.push(key);
}
});
if (changedKeys.length) {
if (verbose) {
changedKeys.forEach((key) => {
const newValue = newProps[key];
const lastValue = lastProps[key];
console.log('Key [', key, '] changed');
console.log('From: ', lastValue);
console.log('To: ', newValue);
console.log('------');
});
} else {
console.log('Changed keys: ', changedKeys.join());
}
}
}
export function useDebugPropChanges(
newProps: Record<string, unknown>,
verbose: boolean = true
) {
const lastProps = useRef<Record<string, unknown>>();
// Should only run when the component re-mounts
useEffect(() => {
console.log('Mounted');
}, []);
if (lastProps.current) {
logPropDifferences(newProps, lastProps.current, verbose);
}
lastProps.current = newProps;
}
/* eslint-enable no-console */

View File

@@ -3,7 +3,7 @@ import { StreamLanguage, LanguageSupport } 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 { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { createTheme } from '@uiw/codemirror-themes';
import { tags as highlightTags } from '@lezer/highlight';
@@ -11,6 +11,8 @@ 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';
@@ -89,17 +91,17 @@ export function CodeEditor({
return extensions;
}, [type]);
function handleVersionChange(version: number) {
if (versions && versions.length > 1) {
if (version < versions[0]) {
setIsRollback(true);
} else {
setIsRollback(false);
const handleVersionChange = useCallback(
(version: number) => {
if (versions && versions.length > 1) {
setIsRollback(version < versions[0]);
}
}
onVersionChange?.(version);
},
[onVersionChange, versions]
);
onVersionChange?.(version);
}
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
return (
<>
@@ -136,8 +138,8 @@ export function CodeEditor({
<CodeMirror
className={styles.root}
theme={theme}
value={value}
onChange={onChange}
value={debouncedValue}
onChange={debouncedOnChange}
readOnly={readonly || isRollback}
id={id}
extensions={extensions}

View File

@@ -40,17 +40,10 @@ export function useDocsUrl(doc?: string): string {
}
let url = 'https://docs.portainer.io/';
if (versionQuery.data) {
let { ServerVersion } = versionQuery.data;
if (ServerVersion[0] === 'v') {
ServerVersion = ServerVersion.substring(1);
}
const parts = ServerVersion.split('.');
if (parts.length >= 2) {
const version = parts.slice(0, 2).join('.');
url += `v/${version}`;
}
// Add LTS or STS version if we have it
if (versionQuery.data?.VersionSupport) {
url += versionQuery.data.VersionSupport.toLowerCase();
}
if (doc) {

View File

@@ -104,6 +104,7 @@ export function TagSelector({
onCreateOption={handleCreateOption}
aria-label="Tags"
data-cy="environment-tags-selector"
id="environment-tags-selector"
/>
</FormControl>
</>

View File

@@ -36,6 +36,7 @@ export function UsersSelector({
onChange(selectedUsers.map((user) => user.Id))
}
data-cy={dataCy}
id={dataCy}
inputId={inputId}
placeholder={placeholder}
isDisabled={disabled}

View File

@@ -18,6 +18,7 @@ export function Select<T extends number | string>({
options,
className,
'data-cy': dataCy,
id,
...props
}: Props<T> & SelectHTMLAttributes<HTMLSelectElement>) {
return (

View File

@@ -111,6 +111,7 @@ export function SingleSelect<TValue = string>({
onChange={(option) => onChange(option ? option.value : null)}
isOptionDisabled={(option) => !!option.disabled}
data-cy={dataCy}
id={dataCy}
inputId={inputId}
placeholder={placeholder}
isDisabled={disabled}
@@ -177,6 +178,7 @@ export function MultiSelect<TValue = string>({
closeMenuOnSelect={false}
onChange={(newValue) => onChange(newValue.map((option) => option.value))}
data-cy={dataCy}
id={dataCy}
inputId={inputId}
placeholder={placeholder}
isDisabled={disabled}

View File

@@ -65,6 +65,7 @@ export function Select<
}: Props<Option, IsMulti, Group> &
AutomationTestingProps & {
isItemVisible?: (item: Option, search: string) => boolean;
id: string;
}) {
const Component = isCreatable ? ReactSelectCreatable : ReactSelect;
const { options } = props;

View File

@@ -152,6 +152,7 @@ export function GpuFieldset({
options={options}
components={{ MultiValueRemove }}
data-cy="docker-containers-gpu-select"
id="docker-containers-gpu-select"
/>
</div>
)}
@@ -173,6 +174,7 @@ export function GpuFieldset({
components={{ Option }}
onChange={onChangeSelectedCaps}
data-cy="docker-containers-gpu-capabilities-select"
id="docker-containers-gpu-capabilities-select"
/>
</div>
</div>

View File

@@ -44,6 +44,7 @@ export function VolumeSelector({
onChange={(vol) => onChange(vol?.Name)}
inputId={inputId}
data-cy="docker-containers-volume-selector"
id="docker-containers-volume-selector"
size="sm"
/>
);

View File

@@ -43,6 +43,7 @@ export function CreatableSelector({
isDisabled={isLoading}
closeMenuOnSelect={false}
data-cy="edge-devices-assignment-selector"
id="edge-devices-assignment-selector"
/>
);

View File

@@ -45,6 +45,7 @@ export function GroupSelector() {
placeholder="Select a group"
isClearable
data-cy="edge-devices-assignment-selector"
id="edge-devices-assignment-selector"
/>
);

View File

@@ -29,13 +29,16 @@ export function CreateForm() {
const [webhookId] = useState(() => createWebhookId());
const [templateParams, setTemplateParams] = useTemplateParams();
const templateQuery = useTemplate(templateParams.type, templateParams.id);
const templateQuery = useTemplate(
templateParams.templateType,
templateParams.templateId
);
const validation = useValidation(templateQuery);
const mutation = useCreate({
webhookId,
template: templateQuery.customTemplate || templateQuery.appTemplate,
templateType: templateParams.type,
templateType: templateParams.templateType,
});
const initialValues = useInitialValues(templateQuery, templateParams);
@@ -53,6 +56,7 @@ export function CreateForm() {
initialValues={initialValues}
onSubmit={mutation.onSubmit}
validationSchema={validation}
validateOnMount
>
<InnerForm
webhookId={webhookId}
@@ -118,8 +122,8 @@ function useInitialValues(
customTemplate: CustomTemplate | undefined;
},
templateParams: {
id: number | undefined;
type: 'app' | 'custom' | undefined;
templateId: number | undefined;
templateType: 'app' | 'custom' | undefined;
}
) {
const template = templateQuery.customTemplate || templateQuery.appTemplate;
@@ -139,7 +143,7 @@ function useInitialValues(
staggerConfig:
templateQuery.customTemplate?.EdgeSettings?.StaggerConfig ??
getDefaultStaggerConfig(),
method: templateParams.id ? 'template' : 'editor',
method: templateParams.templateId ? 'template' : 'editor',
git: toGitFormModel(
templateQuery.customTemplate?.GitConfig,
parseAutoUpdateResponse()
@@ -149,19 +153,19 @@ function useInitialValues(
getDefaultRelativePathModel(),
enableWebhook: false,
fileContent: '',
templateValues: getTemplateValues(templateParams.type, template),
templateValues: getTemplateValues(templateParams.templateType, template),
useManifestNamespaces: false,
}),
[
templateQuery.customTemplate,
templateParams.id,
templateParams.type,
templateParams.templateId,
templateParams.templateType,
template,
]
);
if (
templateParams.id &&
templateParams.templateId &&
!templateQuery.customTemplate &&
!templateQuery.appTemplate
) {

View File

@@ -17,7 +17,11 @@ import { useCurrentUser } from '@/react/hooks/useUser';
import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types';
import {
DeployMethod,
GitFormModel,
RelativePathModel,
} from '@/react/portainer/gitops/types';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
@@ -133,7 +137,10 @@ export function useValidation({
);
},
}) as SchemaOf<GitFormModel>,
relativePath: relativePathValidation(),
relativePath: mixed().when('method', {
is: 'repository',
then: () => relativePathValidation(),
}) as SchemaOf<RelativePathModel>,
useManifestNamespaces: boolean().default(false),
})
),

View File

@@ -1,5 +1,5 @@
import { FormikErrors, useFormikContext } from 'formik';
import { SetStateAction } from 'react';
import { SetStateAction, useCallback } from 'react';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
@@ -28,8 +28,8 @@ const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
interface Props {
webhookId: string;
onChangeTemplate: (change: {
type: 'app' | 'custom' | undefined;
id: number | undefined;
templateType: 'app' | 'custom' | undefined;
templateId: number | undefined;
}) => void;
}
@@ -37,6 +37,23 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
const { method } = values;
const handleChange = useCallback(
(newValues: Partial<DockerFormValues>) => {
setValues((values) => ({
...values,
...newValues,
}));
},
[setValues]
);
const saveFileContent = useCallback(
(value: string) => {
handleChange({ fileContent: value });
},
[handleChange]
);
return (
<>
<FormSection title="Build Method">
@@ -59,8 +76,8 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
values.templateValues
);
onChangeTemplate({
id: templateValues.templateId,
type: templateValues.type,
templateId: templateValues.templateId,
templateType: templateValues.type,
});
setValues((values) => ({
...values,
@@ -91,7 +108,7 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
{method === editor.value && (
<DockerContentField
value={values.fileContent}
onChange={(value) => handleChange({ fileContent: value })}
onChange={saveFileContent}
error={errors?.fileContent}
/>
)}
@@ -128,6 +145,7 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
<FormSection title="Advanced configurations">
<RelativePathFieldset
values={values.relativePath}
errors={errors.relativePath}
gitModel={values.git}
onChange={(relativePath) =>
setValues((values) => ({
@@ -145,13 +163,6 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
)}
</>
);
function handleChange(newValues: Partial<DockerFormValues>) {
setValues((values) => ({
...values,
...newValues,
}));
}
}
type TemplateContentFieldProps = {

View File

@@ -29,11 +29,11 @@ export function InnerForm({
webhookId: string;
isLoading: boolean;
onChangeTemplate: ({
type,
id,
templateType,
templateId,
}: {
type: 'app' | 'custom' | undefined;
id: number | undefined;
templateType: 'app' | 'custom' | undefined;
templateId: number | undefined;
}) => void;
}) {
const { values, setFieldValue, errors, setValues, setFieldError, isValid } =
@@ -128,6 +128,7 @@ export function InnerForm({
<StaggerFieldset
isEdit={false}
values={values.staggerConfig}
errors={errors.staggerConfig}
onChange={(newStaggerValues) =>
setFieldValue('staggerConfig', newStaggerValues)
}

View File

@@ -50,6 +50,7 @@ export function TemplateSelector({
onChange(getTemplate({ type, id: templateId }), type);
}}
data-cy="edge-stacks-create-template-selector"
id="edge-stacks-create-template-selector"
/>
{isLoadingValues && (
<InlineLoader>Loading template values...</InlineLoader>

View File

@@ -6,6 +6,7 @@ import { TemplateViewModel } from '@/react/portainer/templates/app-templates/vie
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import { notifySuccess } from '@/portainer/services/notifications';
import { transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { mutationOptions, withError } from '@/react-tools/react-query';
import {
BasePayload,
@@ -49,12 +50,18 @@ export function useCreate({
),
});
mutation.mutate(getPayload(method, values), {
onSuccess: () => {
notifySuccess('Success', 'Edge stack created');
router.stateService.go('^');
},
});
mutation.mutate(
getPayload(method, values),
mutationOptions(
{
onSuccess: () => {
notifySuccess('Success', 'Edge stack created');
router.stateService.go('^');
},
},
withError('unable to create edge stack')
)
);
function getPayload(
method: 'string' | 'file' | 'git',

View File

@@ -1,15 +1,14 @@
import { useParamsState } from '@/react/hooks/useParamState';
export function useTemplateParams() {
const [{ id, type }, setTemplateParams] = useParamsState(
['templateId', 'templateType'],
const [{ templateId, templateType }, setTemplateParams] = useParamsState(
(params) => ({
id: parseTemplateId(params.templateId),
type: parseTemplateType(params.templateType),
templateId: parseTemplateId(params.templateId),
templateType: parseTemplateType(params.templateType),
})
);
return [{ id, type }, setTemplateParams] as const;
return [{ templateId, templateType }, setTemplateParams] as const;
}
function parseTemplateId(param?: string) {

View File

@@ -97,6 +97,7 @@ function InnerSelector({
placeholder="Select one or multiple group(s)"
closeMenuOnSelect={false}
data-cy="edge-stacks-groups-selector"
id="edge-stacks-groups-selector"
inputId={inputId}
/>
) : (

View File

@@ -102,6 +102,7 @@ export function PrivateRegistryFieldset({
onChange={(value) => onSelect(value?.Id)}
className="w-full"
data-cy="private-registry-selector"
id="private-registry-selector"
/>
{method !== 'repository' && (
<Button

View File

@@ -1,17 +1,56 @@
import _ from 'lodash';
import { debounce } from 'lodash';
import { useState, useRef, useCallback, useEffect } from 'react';
// `useRef` to keep the debouncer function (result of the _.debounce call) between rerenders.
//
// debouncer func is (value, onChange) => { onChange(value) };
//
// Previously written and used as
// const onChangeDebouncer = useRef(debounce(onChange, 300));
// onChangeDebouncer.current(value)
//
// The issue with the previous syntax is that it was holding the initial state of the `onChange` function passed to `useDebounce()`.
// When the `onChange` function was using a dynamic context (vars in parent scope/not in its parameters)
// then invoking the debouncer was producing a result of `onChange` computed uppon the initial state of the function, not the current state.
//
// Example of the issue
//
// function Component({ value }: { value: string; }) {
//
// function onChange(v: string) {
// // This will always print the first value of the "value" prop + the updated value of "v"
// // when called from "handleChange".
// // This is an issue when the `onChange` is a prop of the component and the real function performs state mutations upflow based on
// // values that are in the parent component, as `setDebouncedValue` will only use the initial instance of the `onChange` prop, thus
// // the initial state of the parent component.
// console.log(value, v)
// }
//
// const [debouncedValue, setDebouncedValue] = useDebounce(value, onChange);
//
// function handleChange(newValue: string) {
// setDebouncedValue(newValue);
// }
//
// return (<Input value={debouncedValue} onChange={(e) => handleChange(e.target.value)} />)
// }
export function useDebounce(value: string, onChange: (value: string) => void) {
const [debouncedValue, setDebouncedValue] = useState(value);
const onChangeDebounces = useRef(_.debounce(onChange, 300));
// Do not change. See notes above
const onChangeDebouncer = useRef(
debounce(
(value: string, onChangeFunc: (v: string) => void) => onChangeFunc(value),
300
)
);
const handleChange = useCallback(
(value: string) => {
setDebouncedValue(value);
onChangeDebounces.current(value);
onChangeDebouncer.current(value, onChange);
},
[onChangeDebounces, setDebouncedValue]
[onChangeDebouncer, setDebouncedValue, onChange]
);
useEffect(() => {

View File

@@ -22,32 +22,15 @@ export function useParamState<T>(
/** Use this when you need to use/update multiple params at once. */
export function useParamsState<T extends Record<string, unknown>>(
params: string[],
parseParams: (params: Record<string, string | undefined>) => T
) {
const { params: stateParams } = useCurrentStateAndParams();
const router = useRouter();
const state = parseParams(
params.reduce(
(acc, param) => {
acc[param] = stateParams[param];
return acc;
},
{} as Record<string, string | undefined>
)
);
const state = parseParams(stateParams);
function setState(newState: Partial<T>) {
const newStateParams = Object.entries(newState).reduce(
(acc, [key, value]) => {
acc[key] = value;
return acc;
},
{} as Record<string, unknown>
);
router.stateService.go('.', newStateParams, { reload: false });
router.stateService.go('.', newState, { reload: false });
}
return [state, setState] as const;

View File

@@ -122,6 +122,7 @@ export function AppIngressPathForm({
onChangeIngressPath(newIngressPath);
}}
data-cy="k8sAppCreate-ingressPathHostSelect"
id="k8sAppCreate-ingressPathHostSelect"
/>
<InputGroup.ButtonWrapper>
<Button

View File

@@ -7,7 +7,7 @@ export function HelmInsightsBox() {
content={
<span>
From 2.20 and on, the Helm menu sidebar option has moved to the{' '}
<strong>Create from file screen</strong> - accessed via the button
<strong>Create from code screen</strong> - accessed via the button
above.
</span>
}

View File

@@ -7,64 +7,74 @@ import { Icon } from '@@/Icon';
import { Application } from './types';
export function PublishedPorts({ item }: { item: Application }) {
const urls = getPublishedUrls(item);
const urlsWithTypes = getPublishedUrls(item);
if (urls.length === 0) {
if (urlsWithTypes.length === 0) {
return null;
}
return (
<div className="published-url-container">
<div>
<div className="text-muted"> Published URL(s) </div>
</div>
<div>
{urls.map((url) => (
<div key={url}>
<a
href={url}
target="_blank"
className="publish-url-link vertical-center"
rel="noreferrer"
>
<Icon icon={ExternalLinkIcon} />
{url}
</a>
</div>
<div className="published-url-container pl-10 flex">
<div className="text-muted mr-12">Published URL(s)</div>
<div className="flex flex-col">
{urlsWithTypes.map(({ url, type }) => (
<a
key={url}
href={url}
target="_blank"
className="publish-url-link vertical-center mb-1"
rel="noreferrer"
>
<Icon icon={ExternalLinkIcon} />
{type && (
<span className="text-muted w-24 inline-block">{type}</span>
)}
<span>{url}</span>
</a>
))}
</div>
</div>
);
}
function getClusterIPUrls(services?: Application['Services']) {
return (
services?.flatMap(
(service) =>
(service.spec?.type === 'ClusterIP' &&
service.spec?.ports?.map((port) => ({
url: `${getSchemeFromPort(port.port)}://${service?.spec
?.clusterIP}:${port.port}`,
type: 'ClusterIP',
}))) ||
[]
) || []
);
}
function getNodePortUrls(services?: Application['Services']) {
return (
services?.flatMap(
(service) =>
(service.spec?.type === 'NodePort' &&
service.spec?.ports?.map((port) => ({
url: `${getSchemeFromPort(port.port)}://${
window.location.hostname
}:${port.nodePort}`,
type: 'NodePort',
}))) ||
[]
) || []
);
}
export function getPublishedUrls(item: Application) {
// Map all ingress rules in published ports to their respective URLs
const ingressUrls =
item.PublishedPorts?.flatMap((pp) => pp.IngressRules)
.filter(({ Host, IP }) => Host || IP)
.map(({ Host, IP, Path, TLS }) => {
const scheme =
TLS &&
TLS.filter((tls) => tls.hosts && tls.hosts.includes(Host)).length > 0
? 'https'
: 'http';
return `${scheme}://${Host || IP}${Path}`;
}) || [];
// Get URLs from clusterIP and nodePort services
const clusterIPs = getClusterIPUrls(item.Services);
const nodePortUrls = getNodePortUrls(item.Services);
// Map all load balancer service ports to ip address
const loadBalancerURLs =
(item.LoadBalancerIPAddress &&
item.PublishedPorts?.map(
(pp) =>
`${getSchemeFromPort(pp.Port)}://${item.LoadBalancerIPAddress}:${
pp.Port
}`
)) ||
[];
// combine all urls
const publishedUrls = [...clusterIPs, ...nodePortUrls];
// combine ingress urls
const publishedUrls = [...ingressUrls, ...loadBalancerURLs];
// Return the first URL - priority given to ingress urls, then services (load balancers)
return publishedUrls.length > 0 ? publishedUrls : [];
}

View File

@@ -60,6 +60,7 @@ export function ConfigurationItem({
onChange={onSelectConfigMap}
size="sm"
data-cy={`k8sAppCreate-add${configurationType}Select_${index}`}
id={`k8sAppCreate-add${configurationType}Select_${index}`}
/>
</InputGroup>
{formikError?.selectedConfiguration && (

View File

@@ -144,6 +144,7 @@ export function PersistedFolderItem({
applicationValues.Containers.length > 1
}
data-cy={`k8sAppCreate-persistentFolderSizeUnitSelect_${index}`}
id={`k8sAppCreate-persistentFolderSizeUnitSelect_${index}`}
/>
</InputGroup>
{formikError?.size && <FormError>{formikError?.size}</FormError>}
@@ -175,6 +176,7 @@ export function PersistedFolderItem({
storageClasses.length <= 1
}
data-cy={`k8sAppCreate-storageSelect_${index}`}
id={`k8sAppCreate-storageSelect_${index}`}
/>
</InputGroup>
</>
@@ -207,6 +209,7 @@ export function PersistedFolderItem({
availableVolumes.length < 1
}
data-cy={`k8sAppCreate-pvcSelect_${index}`}
id={`k8sAppCreate-pvcSelect_${index}`}
/>
</InputGroup>
)}

View File

@@ -49,6 +49,7 @@ export function PlacementItem({
className={clsx({ striked: !!item.needsDeletion })}
isDisabled={!!item.needsDeletion}
data-cy={`k8sAppCreate-placementLabel_${index}`}
id={`k8sAppCreate-placementLabel_${index}`}
/>
{placementError?.label && (
<FormError>{placementError.label}</FormError>
@@ -65,6 +66,7 @@ export function PlacementItem({
className={clsx({ striked: !!item.needsDeletion })}
isDisabled={!!item.needsDeletion}
data-cy={`k8sAppCreate-placementName_${index}`}
id={`k8sAppCreate-placementName_${index}`}
/>
{placementError?.value && (
<FormError>{placementError.value}</FormError>

View File

@@ -2,7 +2,7 @@ import { CronJob, CronJobList } from 'kubernetes-types/batch/v1';
import { useQuery } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
import { withGlobalError } from '@/react-tools/react-query';
import axios from '@/portainer/services/axios';
import { parseKubernetesAxiosError } from '../axiosError';
@@ -24,7 +24,7 @@ export function useCronJobs(
queryKeys.cronJobsForCluster(environmentId),
() => getCronJobsForCluster(environmentId, namespaces),
{
...withError('Unable to retrieve CronJobs'),
...withGlobalError('Unable to retrieve CronJobs'),
enabled: !!namespaces?.length,
}
);

View File

@@ -2,7 +2,7 @@ import { Job, JobList } from 'kubernetes-types/batch/v1';
import { useQuery } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
import { withGlobalError } from '@/react-tools/react-query';
import axios from '@/portainer/services/axios';
import { parseKubernetesAxiosError } from '../axiosError';
@@ -21,7 +21,7 @@ export function useJobs(environmentId: EnvironmentId, namespaces?: string[]) {
queryKeys.jobsForCluster(environmentId),
() => getJobsForCluster(environmentId, namespaces),
{
...withError('Unable to retrieve Jobs'),
...withGlobalError('Unable to retrieve Jobs'),
enabled: !!namespaces?.length,
}
);

View File

@@ -69,11 +69,10 @@ export function getTotalPods(
): number {
switch (application.kind) {
case 'Deployment':
return application.status?.replicas ?? 0;
case 'StatefulSet':
return application.spec?.replicas ?? 0;
case 'DaemonSet':
return application.status?.desiredNumberScheduled ?? 0;
case 'StatefulSet':
return application.status?.replicas ?? 0;
default:
throw new Error('Unknown application type');
}

View File

@@ -35,6 +35,7 @@ export function StorageAccessModeSelector({
inputId={inputId}
placeholder="Not configured"
data-cy={`kubeSetup-storageAccessSelect${storageClassName}`}
id={`kubeSetup-storageAccessSelect${storageClassName}`}
/>
);
}

View File

@@ -43,6 +43,7 @@ export function NamespacesSelector({
onChange(selectedTeams.map((namespace) => namespace.name))
}
data-cy={dataCy}
id={dataCy}
inputId={inputId}
placeholder={placeholder}
/>

View File

@@ -18,7 +18,7 @@ export function CreateFromManifestButton({
}}
data-cy={dataCy}
>
Create from file
Create from code
</AddButton>
);
}

View File

@@ -184,6 +184,7 @@ export function IngressForm({
}
noOptionsMessage={() => 'No namespaces available'}
data-cy="k8sAppCreate-namespaceSelect"
id="k8sAppCreate-namespaceSelect"
/>
)}
</div>
@@ -266,6 +267,7 @@ export function IngressForm({
}
noOptionsMessage={() => 'No ingress classes available'}
data-cy="k8sAppCreate-ingressClassSelect"
id="k8sAppCreate-ingressClassSelect"
/>
{errors.className && (
<FormError className="error-inline mt-1">
@@ -464,6 +466,7 @@ export function IngressForm({
noOptionsMessage={() => 'No TLS secrets available'}
size="sm"
data-cy={`k8sAppCreate-tlsSelect_${hostIndex}`}
id={`k8sAppCreate-tlsSelect_${hostIndex}`}
/>
{!host.NoHost && (
<div className="input-group-btn">

View File

@@ -0,0 +1,202 @@
import { useMemo } from 'react';
import { Trash2, CalendarSync } from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription';
import {
DefaultDatatableSettings,
TableSettings as KubeTableSettings,
} from '@/react/kubernetes/datatables/DefaultDatatableSettings';
import { useKubeStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
import { CreateFromManifestButton } from '@/react/kubernetes/components/CreateFromManifestButton';
import { confirmDelete } from '@@/modals/confirm';
import { TableSettingsMenu } from '@@/datatables';
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { LoadingButton } from '@@/buttons';
import {
type FilteredColumnsTableSettings,
filteredColumnsSettings,
} from '@@/datatables/types';
import { mergeOptions } from '@@/datatables/extend-options/mergeOptions';
import { withColumnFilters } from '@@/datatables/extend-options/withColumnFilters';
import { Job } from '../JobsDatatable/types';
import { useCronJobs } from './queries/useCronJobs';
import { columns } from './columns';
import { CronJob } from './types';
import { useDeleteCronJobsMutation } from './queries/useDeleteCronJobsMutation';
import { CronJobsExecutionsInnerDatatable } from './CronJobsExecutionsInnerDatatable';
const storageKey = 'cronJobs';
interface TableSettings
extends KubeTableSettings,
FilteredColumnsTableSettings {}
interface CronJobsExecutionsProps {
item: Job[];
tableState: TableSettings;
}
export function CronJobsDatatable() {
const environmentId = useEnvironmentId();
const tableState = useKubeStore<TableSettings>(
storageKey,
undefined,
(set) => ({
...filteredColumnsSettings(set),
})
);
const cronJobsQuery = useCronJobs(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
});
const cronJobsRowData = cronJobsQuery.data;
const { authorized: canAccessSystemResources } = useAuthorizations(
'K8sAccessSystemNamespaces'
);
const filteredCronJobs = useMemo(
() =>
tableState.showSystemResources
? cronJobsRowData
: cronJobsRowData?.filter(
(cronJob) =>
(canAccessSystemResources && tableState.showSystemResources) ||
!cronJob.IsSystem
),
[cronJobsRowData, tableState.showSystemResources, canAccessSystemResources]
);
return (
<ExpandableDatatable
dataset={filteredCronJobs || []}
columns={columns}
settingsManager={tableState}
isLoading={cronJobsQuery.isLoading}
title="Cron Jobs"
titleIcon={CalendarSync}
getRowId={(row) => row.Id}
isRowSelectable={(row) => !row.original.IsSystem}
renderTableActions={(selectedRows) => (
<TableActions selectedItems={selectedRows} />
)}
renderTableSettings={() => (
<TableSettingsMenu>
<DefaultDatatableSettings settings={tableState} />
</TableSettingsMenu>
)}
description={
<SystemResourceDescription
showSystemResources={tableState.showSystemResources}
/>
}
data-cy="k8s-cronJobs-datatable"
extendTableOptions={mergeOptions(
withColumnFilters(tableState.columnFilters, tableState.setColumnFilters)
)}
getRowCanExpand={(row) => (row.original.Jobs ?? []).length > 0}
renderSubRow={(row) => (
<SubRow item={row.original.Jobs ?? []} tableState={tableState} />
)}
/>
);
}
function SubRow({ item, tableState }: CronJobsExecutionsProps) {
return (
<tr>
<td colSpan={8}>
<CronJobsExecutionsInnerDatatable item={item} tableState={tableState} />
</td>
</tr>
);
}
interface SelectedCronJob {
Namespace: string;
Name: string;
}
type TableActionsProps = {
selectedItems: CronJob[];
};
function TableActions({ selectedItems }: TableActionsProps) {
const environmentId = useEnvironmentId();
const deleteCronJobsMutation = useDeleteCronJobsMutation(environmentId);
const router = useRouter();
return (
<Authorized authorizations="K8sCronJobsW">
<LoadingButton
className="btn-wrapper"
color="dangerlight"
disabled={selectedItems.length === 0}
onClick={() => handleRemoveClick(selectedItems)}
icon={Trash2}
isLoading={deleteCronJobsMutation.isLoading}
loadingText="Removing Cron Jobs..."
data-cy="k8s-cronJobs-removeCronJobButton"
>
Remove
</LoadingButton>
<CreateFromManifestButton
params={{ tab: 'cronJobs' }}
data-cy="k8s-cronJobs-deploy-button"
/>
</Authorized>
);
async function handleRemoveClick(cronJobs: SelectedCronJob[]) {
const confirmed = await confirmDelete(
<>
<p>Are you sure you want to delete the selected Cron Jobs?</p>
<ul className="mt-2 max-h-96 list-inside overflow-hidden overflow-y-auto text-sm">
{cronJobs.map((s, index) => (
<li key={index}>
{s.Namespace}/{s.Name}
</li>
))}
</ul>
</>
);
if (!confirmed) {
return null;
}
const payload: Record<string, string[]> = {};
cronJobs.forEach((r) => {
payload[r.Namespace] = payload[r.Namespace] || [];
payload[r.Namespace].push(r.Name);
});
deleteCronJobsMutation.mutate(
{ environmentId, data: payload },
{
onSuccess: () => {
notifySuccess(
'Cron Jobs successfully removed',
cronJobs.map((r) => `${r.Namespace}/${r.Name}`).join(', ')
);
router.stateService.reload();
},
onError: (error) => {
notifyError(
'Unable to delete Cron Jobs',
error as Error,
cronJobs.map((r) => `${r.Namespace}/${r.Name}`).join(', ')
);
},
}
);
return cronJobs;
}
}

View File

@@ -0,0 +1,48 @@
import { CalendarCheck2 } from 'lucide-react';
import {
DefaultDatatableSettings,
TableSettings as KubeTableSettings,
} from '@/react/kubernetes/datatables/DefaultDatatableSettings';
import { Datatable, TableSettingsMenu } from '@@/datatables';
import {
type FilteredColumnsTableSettings,
BasicTableSettings,
} from '@@/datatables/types';
import { TableState } from '@@/datatables/useTableState';
import { columns } from '../JobsDatatable/columns';
import { Job } from '../JobsDatatable/types';
interface TableSettings
extends KubeTableSettings,
FilteredColumnsTableSettings {}
interface CronJobsExecutionsProps {
item: Job[];
tableState: TableSettings;
}
export function CronJobsExecutionsInnerDatatable({
item,
tableState,
}: CronJobsExecutionsProps) {
return (
<Datatable
dataset={item}
columns={columns}
getRowId={(row) => row.Id}
disableSelect
title="Executions"
titleIcon={CalendarCheck2}
data-cy="k8s-cronJobs-executions-datatable"
renderTableSettings={() => (
<TableSettingsMenu>
<DefaultDatatableSettings settings={tableState} />
</TableSettingsMenu>
)}
settingsManager={tableState as unknown as TableState<BasicTableSettings>}
/>
);
}

View File

@@ -0,0 +1,7 @@
import { columnHelper } from './helper';
export const command = columnHelper.accessor((row) => row.Command, {
header: 'Command',
id: 'command',
cell: ({ getValue }) => getValue(),
});

View File

@@ -0,0 +1,10 @@
import { buildExpandColumn } from '@@/datatables/expand-column';
import { CronJob } from '../types';
import { columnHelper } from './helper';
export const expand = columnHelper.display({
...buildExpandColumn<CronJob>(),
id: 'expand',
});

View File

@@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { CronJob } from '../types';
export const columnHelper = createColumnHelper<CronJob>();

View File

@@ -0,0 +1,17 @@
import { expand } from './expand';
import { name } from './name';
import { namespace } from './namespace';
import { schedule } from './schedule';
import { suspend } from './suspend';
import { timezone } from './timezone';
import { command } from './command';
export const columns = [
expand,
name,
namespace,
command,
schedule,
suspend,
timezone,
];

View File

@@ -0,0 +1,23 @@
import { SystemBadge } from '@@/Badge/SystemBadge';
import { columnHelper } from './helper';
export const name = columnHelper.accessor(
(row) => {
let result = row.Name;
if (row.IsSystem) {
result += ' system';
}
return result;
},
{
header: 'Name',
id: 'name',
cell: ({ row }) => (
<div className="flex gap-2">
{row.original.Name}
{row.original.IsSystem && <SystemBadge />}
</div>
),
}
);

View File

@@ -0,0 +1,32 @@
import { Row } from '@tanstack/react-table';
import { filterHOC } from '@@/datatables/Filter';
import { Link } from '@@/Link';
import { CronJob } from '../types';
import { columnHelper } from './helper';
export const namespace = columnHelper.accessor((row) => row.Namespace, {
header: 'Namespace',
id: 'namespace',
cell: ({ getValue, row }) => (
<Link
to="kubernetes.resourcePools.resourcePool"
params={{
id: getValue(),
}}
title={getValue()}
data-cy={`cronJob-namespace-link-${row.original.Name}`}
>
{getValue()}
</Link>
),
meta: {
filter: filterHOC('Filter by namespace'),
},
enableColumnFilter: true,
filterFn: (row: Row<CronJob>, _columnId: string, filterValue: string[]) =>
filterValue.length === 0 ||
filterValue.includes(row.original.Namespace ?? ''),
});

View File

@@ -0,0 +1,7 @@
import { columnHelper } from './helper';
export const schedule = columnHelper.accessor((row) => row.Schedule, {
header: 'Schedule',
id: 'schedule',
cell: ({ getValue }) => getValue(),
});

View File

@@ -0,0 +1,10 @@
import { columnHelper } from './helper';
export const suspend = columnHelper.accessor((row) => row.Suspend, {
header: 'Suspend',
id: 'suspend',
cell: ({ getValue }) => {
const suspended = getValue();
return suspended ? 'Yes' : 'No';
},
});

View File

@@ -0,0 +1,7 @@
import { columnHelper } from './helper';
export const timezone = columnHelper.accessor((row) => row.Timezone, {
header: 'Timezone',
id: 'timezone',
cell: ({ getValue }) => getValue(),
});

Some files were not shown because too many files have changed in this diff Show More