Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d16462195f | |||
| 55c98912ed | |||
| 45bd7984b0 | |||
| 1ed9a0106e | |||
| f8b2ee8c0d | |||
| d32b0f8b7e | |||
| 24fdb1f600 | |||
| 4010174f66 | |||
| e2b812a611 | |||
| d72b3a9ba2 | |||
| 85f52d2574 | |||
| 33ea22c0a9 | |||
| 0d52f9dd0e | |||
| 3caffe1e85 | |||
| 87b8dd61c3 | |||
| ad77cd195c | |||
| eb2a754580 | |||
| 9258db58db | |||
| 8d1c90f912 | |||
| 1c62bd6ca5 | |||
| 13317ec43c | |||
| 35dcb5ca46 |
@@ -95,10 +95,13 @@ 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.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 +121,6 @@ body:
|
||||
- '2.18.3'
|
||||
- '2.18.2'
|
||||
- '2.18.1'
|
||||
- '2.17.1'
|
||||
- '2.17.0'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
+1
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -610,7 +610,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.25.1",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.26.0",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.25.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.26.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
+6
-1
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+14
-4
@@ -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))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -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 {
|
||||
@@ -69,6 +71,10 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if r.Context().Err() != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stack, err = handler.updateEdgeStackStatus(tx, r, portainer.EdgeStackID(stackID), payload)
|
||||
return err
|
||||
}); err != nil {
|
||||
@@ -80,6 +86,10 @@ 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)
|
||||
}
|
||||
|
||||
@@ -87,18 +97,23 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
// skip error because agent tries to report on deleted stack
|
||||
// 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)
|
||||
}
|
||||
|
||||
if payload.Version > 0 && payload.Version < stack.Version {
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -83,7 +83,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.25.1
|
||||
// @version 2.26.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+5
-10
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+37
-26
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -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,7 +1636,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.25.1"
|
||||
APIVersion = "2.26.0"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))), [])
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+48
@@ -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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const command = columnHelper.accessor((row) => row.Command, {
|
||||
header: 'Command',
|
||||
id: 'command',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { CronJob } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<CronJob>();
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
);
|
||||
@@ -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 ?? ''),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const schedule = columnHelper.accessor((row) => row.Schedule, {
|
||||
header: 'Schedule',
|
||||
id: 'schedule',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
});
|
||||
@@ -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';
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const timezone = columnHelper.accessor((row) => row.Timezone, {
|
||||
header: 'Timezone',
|
||||
id: 'timezone',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { CronJobsDatatable } from './CronJobsDatatable';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export const queryKeys = {
|
||||
list: (environmentId: EnvironmentId) =>
|
||||
['environments', environmentId, 'kubernetes', 'cronJobs'] as const,
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { CronJob } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useCronJobs(
|
||||
environmentId: EnvironmentId,
|
||||
options?: { refetchInterval?: number; enabled?: boolean }
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.list(environmentId),
|
||||
async () => getAllCronJobs(environmentId),
|
||||
{
|
||||
...withGlobalError('Unable to get cron jobs'),
|
||||
refetchInterval() {
|
||||
return options?.refetchInterval ?? false;
|
||||
},
|
||||
enabled: options?.enabled,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getAllCronJobs(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: cronJobs } = await axios.get<CronJob[]>(
|
||||
`kubernetes/${environmentId}/cron_jobs`
|
||||
);
|
||||
|
||||
return cronJobs;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to get cron jobs');
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useDeleteCronJobsMutation(environmentId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(deleteCronJob, {
|
||||
...withInvalidate(queryClient, [queryKeys.list(environmentId)]),
|
||||
...withGlobalError('Unable to delete Cron Jobs'),
|
||||
});
|
||||
}
|
||||
|
||||
type NamespaceCronJobsMap = Record<string, string[]>;
|
||||
|
||||
export async function deleteCronJob({
|
||||
environmentId,
|
||||
data,
|
||||
}: {
|
||||
environmentId: EnvironmentId;
|
||||
data: NamespaceCronJobsMap;
|
||||
}) {
|
||||
try {
|
||||
return await axios.post(
|
||||
`kubernetes/${environmentId}/cron_jobs/delete`,
|
||||
data
|
||||
);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, `Unable to delete Cron Jobs`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Job } from '../JobsDatatable/types';
|
||||
|
||||
export type CronJob = {
|
||||
Id: string;
|
||||
Name: string;
|
||||
Namespace: string;
|
||||
Command: string;
|
||||
Schedule: string;
|
||||
Timezone: string;
|
||||
Suspend: boolean;
|
||||
IsSystem?: boolean;
|
||||
Jobs?: Job[];
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Trash2, CalendarCheck2 } 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 { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
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 { useJobs } from './queries/useJobs';
|
||||
import { columns } from './columns';
|
||||
import { Job } from './types';
|
||||
import { useDeleteJobsMutation } from './queries/useDeleteJobsMutation';
|
||||
|
||||
const storageKey = 'jobs';
|
||||
|
||||
interface TableSettings
|
||||
extends KubeTableSettings,
|
||||
FilteredColumnsTableSettings {}
|
||||
|
||||
export function JobsDatatable() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const tableState = useKubeStore<TableSettings>(
|
||||
storageKey,
|
||||
undefined,
|
||||
(set) => ({
|
||||
...filteredColumnsSettings(set),
|
||||
})
|
||||
);
|
||||
|
||||
const jobsQuery = useJobs(environmentId, {
|
||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||
});
|
||||
const jobsRowData = jobsQuery.data;
|
||||
|
||||
const { authorized: canAccessSystemResources } = useAuthorizations(
|
||||
'K8sAccessSystemNamespaces'
|
||||
);
|
||||
const filteredJobs = useMemo(
|
||||
() =>
|
||||
tableState.showSystemResources
|
||||
? jobsRowData
|
||||
: jobsRowData?.filter(
|
||||
(job) =>
|
||||
// show everything if we can access system resources and the table is set to show them
|
||||
(canAccessSystemResources && tableState.showSystemResources) ||
|
||||
// otherwise, only show non-system resources
|
||||
!job.IsSystem
|
||||
),
|
||||
[jobsRowData, tableState.showSystemResources, canAccessSystemResources]
|
||||
);
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
dataset={filteredJobs || []}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
isLoading={jobsQuery.isLoading}
|
||||
title="Jobs"
|
||||
titleIcon={CalendarCheck2}
|
||||
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-jobs-datatable"
|
||||
extendTableOptions={mergeOptions(
|
||||
withColumnFilters(tableState.columnFilters, tableState.setColumnFilters)
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectedJob {
|
||||
Namespace: string;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
type TableActionsProps = {
|
||||
selectedItems: Job[];
|
||||
};
|
||||
|
||||
function TableActions({ selectedItems }: TableActionsProps) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const deleteJobsMutation = useDeleteJobsMutation(environmentId);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sCronJobsW">
|
||||
<LoadingButton
|
||||
className="btn-wrapper"
|
||||
color="dangerlight"
|
||||
disabled={selectedItems.length === 0}
|
||||
onClick={() => handleRemoveClick(selectedItems)}
|
||||
icon={Trash2}
|
||||
isLoading={deleteJobsMutation.isLoading}
|
||||
loadingText="Removing jobs..."
|
||||
data-cy="k8s-jobs-removeJobButton"
|
||||
>
|
||||
Remove
|
||||
</LoadingButton>
|
||||
|
||||
<CreateFromManifestButton
|
||||
params={{ tab: 'jobs' }}
|
||||
data-cy="k8s-jobs-deploy-button"
|
||||
/>
|
||||
</Authorized>
|
||||
);
|
||||
|
||||
async function handleRemoveClick(jobs: SelectedJob[]) {
|
||||
const confirmed = await confirmDelete(
|
||||
<>
|
||||
<p>Are you sure you want to delete the selected job(s)?</p>
|
||||
<ul className="mt-2 max-h-96 list-inside overflow-hidden overflow-y-auto text-sm">
|
||||
{jobs.map((s, index) => (
|
||||
<li key={index}>
|
||||
{s.Namespace}/{s.Name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
if (!confirmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload: Record<string, string[]> = {};
|
||||
jobs.forEach((r) => {
|
||||
payload[r.Namespace] = payload[r.Namespace] || [];
|
||||
payload[r.Namespace].push(r.Name);
|
||||
});
|
||||
|
||||
deleteJobsMutation.mutate(
|
||||
{ environmentId, data: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess(
|
||||
'Jobs successfully removed',
|
||||
jobs.map((r) => `${r.Namespace}/${r.Name}`).join(', ')
|
||||
);
|
||||
router.stateService.reload();
|
||||
},
|
||||
onError: (error) => {
|
||||
notifyError(
|
||||
'Unable to delete jobs',
|
||||
error as Error,
|
||||
jobs.map((r) => `${r.Namespace}/${r.Name}`).join(', ')
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return jobs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const actions = columnHelper.accessor(() => '', {
|
||||
header: 'Actions',
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
cell: ({ row: { original: job } }) => (
|
||||
<div className="flex gap-x-2">
|
||||
<Link
|
||||
className="flex items-center gap-1"
|
||||
to="kubernetes.applications.application.logs"
|
||||
params={{
|
||||
name: job.PodName,
|
||||
namespace: job.Namespace,
|
||||
pod: job.PodName,
|
||||
container: job.Container?.name,
|
||||
}}
|
||||
data-cy={`job-logs-${job.Namespace}-${job.Name}-${job.Container?.name}`}
|
||||
>
|
||||
<Icon icon={FileText} />
|
||||
Logs
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const command = columnHelper.accessor((row) => row.Command, {
|
||||
header: 'Command',
|
||||
id: 'command',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const duration = columnHelper.accessor((row) => row.Duration, {
|
||||
header: 'Duration',
|
||||
id: 'duration',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const finished = columnHelper.accessor((row) => row.FinishTime, {
|
||||
header: 'Finished',
|
||||
id: 'finished',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { Job } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<Job>();
|
||||
@@ -0,0 +1,19 @@
|
||||
import { name } from './name';
|
||||
import { namespace } from './namespace';
|
||||
import { started } from './started';
|
||||
import { finished } from './finished';
|
||||
import { duration } from './duration';
|
||||
import { status } from './status';
|
||||
import { actions } from './actions';
|
||||
import { command } from './command';
|
||||
|
||||
export const columns = [
|
||||
name,
|
||||
namespace,
|
||||
command,
|
||||
status,
|
||||
started,
|
||||
finished,
|
||||
duration,
|
||||
actions,
|
||||
];
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Row } from '@tanstack/react-table';
|
||||
|
||||
import { filterHOC } from '@@/datatables/Filter';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Job } 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<Job>, _columnId: string, filterValue: string[]) =>
|
||||
filterValue.length === 0 ||
|
||||
filterValue.includes(row.original.Namespace ?? ''),
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { formatDate } from '@/portainer/filters/filters';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const started = columnHelper.accessor(
|
||||
(row) => formatDate(row.StartTime),
|
||||
{
|
||||
header: 'Started',
|
||||
id: 'started',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
.status-indicator {
|
||||
padding: 0 !important;
|
||||
margin-right: 1ch;
|
||||
border-radius: 50%;
|
||||
background-color: var(--red-3);
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-indicator.ok {
|
||||
background-color: var(--green-3);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
|
||||
import { Job } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
import styles from './status.module.css';
|
||||
|
||||
export const status = columnHelper.accessor((row) => row.Status, {
|
||||
header: 'Status',
|
||||
id: 'status',
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
function Cell({ row: { original: item } }: CellContext<Job, string>) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={clsx([
|
||||
styles.statusIndicator,
|
||||
{
|
||||
[styles.ok]: item.Status !== 'Failed',
|
||||
},
|
||||
])}
|
||||
/>
|
||||
{item.Status}
|
||||
{item.Status === 'Failed' && (
|
||||
<span className="ml-1">
|
||||
<TooltipWithChildren
|
||||
message={
|
||||
<div>
|
||||
<span>{item.FailedReason}</span>
|
||||
</div>
|
||||
}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="vertical-center text-muted inline-flex whitespace-nowrap text-base">
|
||||
<HelpCircle className="lucide" aria-hidden="true" />
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { JobsDatatable } from './JobsDatatable';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export const queryKeys = {
|
||||
list: (environmentId: EnvironmentId) =>
|
||||
['environments', environmentId, 'kubernetes', 'jobs'] as const,
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useDeleteJobsMutation(environmentId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(deleteJob, {
|
||||
...withInvalidate(queryClient, [queryKeys.list(environmentId)]),
|
||||
...withGlobalError('Unable to delete Jobs'),
|
||||
});
|
||||
}
|
||||
|
||||
type NamespaceJobsMap = Record<string, string[]>;
|
||||
|
||||
export async function deleteJob({
|
||||
environmentId,
|
||||
data,
|
||||
}: {
|
||||
environmentId: EnvironmentId;
|
||||
data: NamespaceJobsMap;
|
||||
}) {
|
||||
try {
|
||||
return await axios.post(`kubernetes/${environmentId}/jobs/delete`, data);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, `Unable to delete Jobs`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Job } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useJobs(
|
||||
environmentId: EnvironmentId,
|
||||
options?: { refetchInterval?: number; enabled?: boolean }
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.list(environmentId),
|
||||
async () => getAllJobs(environmentId),
|
||||
{
|
||||
...withGlobalError('Unable to get Jobs'),
|
||||
refetchInterval() {
|
||||
return options?.refetchInterval ?? false;
|
||||
},
|
||||
enabled: options?.enabled,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getAllJobs(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: jobs } = await axios.get<Job[]>(
|
||||
`kubernetes/${environmentId}/jobs`
|
||||
);
|
||||
|
||||
return jobs;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to get Jobs');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Container } from 'kubernetes-types/core/v1';
|
||||
|
||||
export type Job = {
|
||||
Id: string;
|
||||
Namespace: string;
|
||||
Name: string;
|
||||
PodName: string;
|
||||
Container?: Container;
|
||||
Command?: string;
|
||||
BackoffLimit?: number;
|
||||
Completions?: number;
|
||||
StartTime?: string;
|
||||
FinishTime?: string;
|
||||
Duration?: number;
|
||||
Status?: string;
|
||||
FailedReason?: string;
|
||||
IsSystem?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { CalendarCheck2, CalendarSync } from 'lucide-react';
|
||||
|
||||
import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { WidgetTabs, Tab, findSelectedTabIndex } from '@@/Widget/WidgetTabs';
|
||||
|
||||
import { JobsDatatable } from './JobsDatatable/JobsDatatable';
|
||||
import { CronJobsDatatable } from './CronJobsDatatable/CronJobsDatatable';
|
||||
|
||||
export function JobsView() {
|
||||
useUnauthorizedRedirect(
|
||||
{ authorizations: ['K8sJobsR', 'K8sCronJobsR'] },
|
||||
{ to: 'kubernetes.dashboard' }
|
||||
);
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{
|
||||
name: 'Cron Jobs',
|
||||
icon: CalendarSync,
|
||||
widget: <CronJobsDatatable />,
|
||||
selectedTabParam: 'cronJobs',
|
||||
},
|
||||
{
|
||||
name: 'Jobs',
|
||||
icon: CalendarCheck2,
|
||||
widget: <JobsDatatable />,
|
||||
selectedTabParam: 'jobs',
|
||||
},
|
||||
];
|
||||
|
||||
const currentTabIndex = findSelectedTabIndex(
|
||||
useCurrentStateAndParams(),
|
||||
tabs
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Cron Jobs & Jobs lists"
|
||||
breadcrumbs="Cron Jobs & Jobs"
|
||||
reload
|
||||
/>
|
||||
<>
|
||||
<WidgetTabs tabs={tabs} currentTabIndex={currentTabIndex} />
|
||||
<div className="content">{tabs[currentTabIndex].widget}</div>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { JobsView } from './JobsView';
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { GitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { PathSelector } from '@/react/portainer/gitops/ComposePathField/PathSelector';
|
||||
import { dummyGitForm } from '@/react/portainer/gitops/RelativePathFieldset/utils';
|
||||
import { useEnableFsPath } from '@/react/portainer/gitops/RelativePathFieldset/useEnableFsPath';
|
||||
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
@@ -30,17 +30,20 @@ export function RelativePathFieldset({
|
||||
hideEdgeConfigs,
|
||||
errors,
|
||||
}: Props) {
|
||||
const { enableFsPath0, enableFsPath1, toggleFsPath } = useEnableFsPath(value);
|
||||
const [relativePathManuallyEnabled, setRelativePathManuallyEnabled] =
|
||||
useState(value.SupportRelativePath);
|
||||
|
||||
const [relativePathForcedEnabled, setRelativePathForcedEnabled] = useState(
|
||||
value.SupportPerDeviceConfigs
|
||||
);
|
||||
|
||||
const gitoptsEdgeConfigDocUrl = useDocsUrl(
|
||||
'/user/edge/stacks/add#gitops-edge-configurations'
|
||||
);
|
||||
|
||||
const pathTip0 =
|
||||
const pathTipSwarm =
|
||||
'For relative path volumes use with Docker Swarm, you must have a network filesystem which all of your nodes can access.';
|
||||
const pathTip1 =
|
||||
'Relative path is active. When you set the ‘local filesystem path’, it will also be utilzed for GitOps Edge configuration.';
|
||||
const pathTip2 =
|
||||
const pathTipGitopsActive =
|
||||
'GitOps Edge configurations is active. When you set the ‘local filesystem path’, it will also be utilized for relative paths.';
|
||||
|
||||
return (
|
||||
@@ -53,10 +56,10 @@ export function RelativePathFieldset({
|
||||
label="Enable relative path volumes"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
tooltip="Enabling this means you can specify relative path volumes in your Compose files, with Portainer pulling the content from your git repository to the environment the stack is deployed to."
|
||||
disabled={isEditing}
|
||||
disabled={isEditing || relativePathForcedEnabled}
|
||||
checked={value.SupportRelativePath}
|
||||
onChange={(value) => {
|
||||
toggleFsPath(0, value);
|
||||
setRelativePathManuallyEnabled(value);
|
||||
handleChange({ SupportRelativePath: value });
|
||||
}}
|
||||
/>
|
||||
@@ -68,31 +71,33 @@ export function RelativePathFieldset({
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="blue">
|
||||
{enableFsPath1 ? pathTip2 : pathTip0}
|
||||
{relativePathForcedEnabled ? pathTipGitopsActive : pathTipSwarm}
|
||||
</TextTip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<FormControl
|
||||
label="Local filesystem path"
|
||||
errors={errors?.FilesystemPath}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
name="FilesystemPath"
|
||||
data-cy="relative-path-filesystem-path-input"
|
||||
placeholder="/mnt"
|
||||
disabled={isEditing || !enableFsPath0}
|
||||
value={value.FilesystemPath}
|
||||
onChange={(e) =>
|
||||
handleChange({ FilesystemPath: e.target.value })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{(!relativePathForcedEnabled || hideEdgeConfigs) && (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<FormControl
|
||||
label="Local filesystem path"
|
||||
errors={errors?.FilesystemPath}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
name="FilesystemPath"
|
||||
data-cy="relative-path-filesystem-path-input"
|
||||
placeholder="/mnt"
|
||||
disabled={isEditing}
|
||||
value={value.FilesystemPath}
|
||||
onChange={(e) =>
|
||||
handleChange({ FilesystemPath: e.target.value })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -117,9 +122,12 @@ export function RelativePathFieldset({
|
||||
tooltip="By enabling the GitOps Edge Configurations feature, you gain the ability to define relative path volumes in your configuration files. Portainer will then automatically fetch the content from your git repository by matching the folder name or file name with the Portainer Edge ID, and apply it to the environment where the stack is deployed"
|
||||
disabled={isEditing}
|
||||
checked={!!value.SupportPerDeviceConfigs}
|
||||
onChange={(value) => {
|
||||
toggleFsPath(1, value);
|
||||
handleChange({ SupportPerDeviceConfigs: value });
|
||||
onChange={(v) => {
|
||||
setRelativePathForcedEnabled(v);
|
||||
handleChange({
|
||||
SupportPerDeviceConfigs: v,
|
||||
SupportRelativePath: v ? true : relativePathManuallyEnabled,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -127,38 +135,32 @@ export function RelativePathFieldset({
|
||||
|
||||
{value.SupportPerDeviceConfigs && (
|
||||
<>
|
||||
{!isEditing && (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="blue">
|
||||
{enableFsPath0 ? pathTip1 : pathTip0}
|
||||
</TextTip>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="blue">{pathTipSwarm}</TextTip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<FormControl
|
||||
label="Local filesystem path"
|
||||
errors={errors?.FilesystemPath}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
name="FilesystemPath"
|
||||
data-cy="per-device-configs-filesystem-path-input"
|
||||
placeholder="/mnt"
|
||||
disabled={isEditing || !enableFsPath1}
|
||||
value={value.FilesystemPath}
|
||||
onChange={(e) =>
|
||||
handleChange({ FilesystemPath: e.target.value })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<FormControl
|
||||
label="Local filesystem path"
|
||||
errors={errors?.FilesystemPath}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
name="FilesystemPath"
|
||||
data-cy="per-device-configs-filesystem-path-input"
|
||||
placeholder="/mnt"
|
||||
disabled={isEditing}
|
||||
value={value.FilesystemPath}
|
||||
onChange={(e) =>
|
||||
handleChange({ FilesystemPath: e.target.value })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { RelativePathModel } from './types';
|
||||
|
||||
export function useEnableFsPath(initialValue: RelativePathModel) {
|
||||
const [state, setState] = useState<number[]>(() =>
|
||||
initialValue.SupportPerDeviceConfigs ? [1] : []
|
||||
);
|
||||
|
||||
const enableFsPath0 = state.length && state[0] === 0;
|
||||
const enableFsPath1 = state.length && state[0] === 1;
|
||||
|
||||
function toggleFsPath(idx: number, enable: boolean) {
|
||||
if (enable) {
|
||||
setState([...state, idx]);
|
||||
} else {
|
||||
setState(state.filter((e) => e !== idx));
|
||||
}
|
||||
}
|
||||
|
||||
return { enableFsPath0, enableFsPath1, toggleFsPath };
|
||||
}
|
||||
@@ -100,24 +100,32 @@ export function KubernetesSidebar({ environmentId }: Props) {
|
||||
data-cy="k8sSidebar-volumes"
|
||||
/>
|
||||
|
||||
<Authorized
|
||||
authorizations="K8sMoreResourcesRW"
|
||||
adminOnlyCE
|
||||
environmentId={environmentId}
|
||||
<SidebarParent
|
||||
label="More Resources"
|
||||
to="kubernetes.moreResources.jobs"
|
||||
pathOptions={{
|
||||
includePaths: [
|
||||
'kubernetes.moreResources.serviceAccounts',
|
||||
'kubernetes.moreResources.clusterRoles',
|
||||
'kubernetes.moreResources.roles',
|
||||
],
|
||||
}}
|
||||
icon={LayoutList}
|
||||
params={{ endpointId: environmentId }}
|
||||
data-cy="k8sSidebar-moreResources"
|
||||
listId="k8sSidebar-moreResources"
|
||||
>
|
||||
<SidebarParent
|
||||
label="More Resources"
|
||||
to="kubernetes.moreResources.serviceAccounts"
|
||||
pathOptions={{
|
||||
includePaths: [
|
||||
'kubernetes.moreResources.clusterRoles',
|
||||
'kubernetes.moreResources.roles',
|
||||
],
|
||||
}}
|
||||
icon={LayoutList}
|
||||
<SidebarItem
|
||||
to="kubernetes.moreResources.jobs"
|
||||
params={{ endpointId: environmentId }}
|
||||
data-cy="k8sSidebar-moreResources"
|
||||
listId="k8sSidebar-moreResources"
|
||||
label="Cron Jobs & Jobs"
|
||||
data-cy="k8sSidebar-jobs"
|
||||
isSubMenu
|
||||
/>
|
||||
<Authorized
|
||||
authorizations="K8sMoreResourcesRW"
|
||||
adminOnlyCE
|
||||
environmentId={environmentId}
|
||||
>
|
||||
<SidebarItem
|
||||
to="kubernetes.moreResources.serviceAccounts"
|
||||
@@ -140,8 +148,8 @@ export function KubernetesSidebar({ environmentId }: Props) {
|
||||
data-cy="k8sSidebar-Roles"
|
||||
isSubMenu
|
||||
/>
|
||||
</SidebarParent>
|
||||
</Authorized>
|
||||
</Authorized>
|
||||
</SidebarParent>
|
||||
|
||||
<SidebarParent
|
||||
label="Cluster"
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
} from '@uirouter/react';
|
||||
|
||||
export type PathOptions = {
|
||||
/** ignorePaths ignores highlighting the sidebar parent when the URL of a sidebar child matches the current URL */
|
||||
ignorePaths?: string[];
|
||||
/** includePaths help to highlight the sidebar parent when the URL of a sidebar child matches the current URL */
|
||||
includePaths?: string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ require (
|
||||
github.com/gofrs/uuid v4.2.0+incompatible
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0
|
||||
github.com/google/go-cmp v0.6.0
|
||||
github.com/gorilla/csrf v1.7.1
|
||||
github.com/gorilla/csrf v1.7.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/hashicorp/golang-lru v0.5.4
|
||||
@@ -42,6 +42,7 @@ require (
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/rs/zerolog v1.29.0
|
||||
github.com/segmentio/encoding v0.3.6
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/urfave/negroni v1.0.0
|
||||
github.com/viney-shih/go-lock v1.1.1
|
||||
@@ -60,7 +61,7 @@ require (
|
||||
software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
|
||||
)
|
||||
|
||||
require github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
require github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
@@ -207,7 +208,6 @@ require (
|
||||
github.com/sergi/go-diff v1.3.1 // indirect
|
||||
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect
|
||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/skeema/knownhosts v1.2.1 // indirect
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
|
||||
github.com/spf13/cobra v1.8.1 // indirect
|
||||
|
||||
@@ -313,13 +313,13 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE=
|
||||
github.com/gorilla/csrf v1.7.1/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA=
|
||||
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.25.1",
|
||||
"version": "2.26.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
@@ -30,6 +30,19 @@ func GetPortainerURLFromEdgeKey(edgeKey string) (string, error) {
|
||||
return keyInfo[0], nil
|
||||
}
|
||||
|
||||
// GetEndpointIDFromEdgeKey returns the endpoint ID from an edge key
|
||||
// format: <portainer_instance_url>|<tunnel_server_addr>|<tunnel_server_fingerprint>|<endpoint_id>
|
||||
func GetEndpointIDFromEdgeKey(edgeKey string) (int, error) {
|
||||
decodedKey, err := base64.RawStdEncoding.DecodeString(edgeKey)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
keyInfo := strings.Split(string(decodedKey), "|")
|
||||
|
||||
return strconv.Atoi(keyInfo[3])
|
||||
}
|
||||
|
||||
// IsValidEdgeStackName validates an edge stack name
|
||||
// Edge stack name must be between 1 and 255 characters long
|
||||
// and can only contain lowercase letters, digits, hyphens and underscores
|
||||
|
||||
@@ -23,12 +23,18 @@ import (
|
||||
"github.com/docker/compose/v2/pkg/compose"
|
||||
"github.com/docker/docker/registry"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const PortainerEdgeStackLabel = "io.portainer.edge_stack_id"
|
||||
|
||||
var mu sync.Mutex
|
||||
|
||||
func init() {
|
||||
// Redirect Compose logging to zerolog
|
||||
logrus.SetOutput(log.Logger)
|
||||
}
|
||||
|
||||
func withCli(
|
||||
ctx context.Context,
|
||||
options libstack.Options,
|
||||
@@ -36,7 +42,7 @@ func withCli(
|
||||
) error {
|
||||
ctx = context.Background()
|
||||
|
||||
cli, err := command.NewDockerCli()
|
||||
cli, err := command.NewDockerCli(command.WithCombinedStreams(log.Logger))
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create a Docker client: %w", err)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
|
||||
require.True(t, containerExists(composeContainerName))
|
||||
|
||||
waitResult := <-w.WaitForStatus(ctx, projectName, libstack.StatusCompleted)
|
||||
waitResult := w.WaitForStatus(ctx, projectName, libstack.StatusCompleted)
|
||||
|
||||
require.Empty(t, waitResult.ErrorMsg)
|
||||
require.Equal(t, libstack.StatusCompleted, waitResult.Status)
|
||||
|
||||
@@ -111,74 +111,66 @@ func aggregateStatuses(services []service) (libstack.Status, string) {
|
||||
|
||||
}
|
||||
|
||||
func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status libstack.Status) <-chan libstack.WaitResult {
|
||||
waitResultCh := make(chan libstack.WaitResult)
|
||||
func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status libstack.Status) libstack.WaitResult {
|
||||
waitResult := libstack.WaitResult{Status: status}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
waitResult.ErrorMsg = "failed to wait for status: " + ctx.Err().Error()
|
||||
waitResultCh <- waitResult
|
||||
default:
|
||||
}
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
waitResult.ErrorMsg = "failed to wait for status: " + ctx.Err().Error()
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
return waitResult
|
||||
}
|
||||
|
||||
var containerSummaries []api.ContainerSummary
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
if err := withComposeService(ctx, nil, libstack.Options{ProjectName: name}, func(composeService api.Service, project *types.Project) error {
|
||||
var err error
|
||||
var containerSummaries []api.ContainerSummary
|
||||
|
||||
psCtx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancelFunc()
|
||||
containerSummaries, err = composeService.Ps(psCtx, name, api.PsOptions{All: true})
|
||||
if err := withComposeService(ctx, nil, libstack.Options{ProjectName: name}, func(composeService api.Service, project *types.Project) error {
|
||||
var err error
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
log.Debug().
|
||||
Str("project_name", name).
|
||||
Err(err).
|
||||
Msg("error from docker compose ps")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
services := serviceListFromContainerSummary(containerSummaries)
|
||||
|
||||
if len(services) == 0 && status == libstack.StatusRemoved {
|
||||
waitResultCh <- waitResult
|
||||
return
|
||||
}
|
||||
|
||||
aggregateStatus, errorMessage := aggregateStatuses(services)
|
||||
if aggregateStatus == status {
|
||||
waitResultCh <- waitResult
|
||||
return
|
||||
}
|
||||
|
||||
if status == libstack.StatusRunning && aggregateStatus == libstack.StatusCompleted {
|
||||
waitResult.Status = libstack.StatusCompleted
|
||||
waitResultCh <- waitResult
|
||||
return
|
||||
}
|
||||
|
||||
if errorMessage != "" {
|
||||
waitResult.ErrorMsg = errorMessage
|
||||
waitResultCh <- waitResult
|
||||
return
|
||||
}
|
||||
psCtx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancelFunc()
|
||||
containerSummaries, err = composeService.Ps(psCtx, name, api.PsOptions{All: true})
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
log.Debug().
|
||||
Str("project_name", name).
|
||||
Str("required_status", string(status)).
|
||||
Str("status", string(aggregateStatus)).
|
||||
Msg("waiting for status")
|
||||
}
|
||||
}()
|
||||
Err(err).
|
||||
Msg("error from docker compose ps")
|
||||
|
||||
return waitResultCh
|
||||
continue
|
||||
}
|
||||
|
||||
services := serviceListFromContainerSummary(containerSummaries)
|
||||
|
||||
if len(services) == 0 && status == libstack.StatusRemoved {
|
||||
return waitResult
|
||||
}
|
||||
|
||||
aggregateStatus, errorMessage := aggregateStatuses(services)
|
||||
if aggregateStatus == status {
|
||||
return waitResult
|
||||
}
|
||||
|
||||
if status == libstack.StatusRunning && aggregateStatus == libstack.StatusCompleted {
|
||||
waitResult.Status = libstack.StatusCompleted
|
||||
|
||||
return waitResult
|
||||
}
|
||||
|
||||
if errorMessage != "" {
|
||||
waitResult.ErrorMsg = errorMessage
|
||||
|
||||
return waitResult
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("project_name", name).
|
||||
Str("required_status", string(status)).
|
||||
Str("status", string(aggregateStatus)).
|
||||
Msg("waiting for status")
|
||||
}
|
||||
}
|
||||
|
||||
func serviceListFromContainerSummary(containerSummaries []api.ContainerSummary) []service {
|
||||
|
||||
@@ -106,8 +106,7 @@ func waitForStatus(deployer libstack.Deployer, ctx context.Context, stackName st
|
||||
ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
statusCh := deployer.WaitForStatus(ctx, stackName, requiredStatus)
|
||||
result := <-statusCh
|
||||
result := deployer.WaitForStatus(ctx, stackName, requiredStatus)
|
||||
if result.ErrorMsg == "" {
|
||||
return result.Status, "", nil
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type Deployer interface {
|
||||
Pull(ctx context.Context, filePaths []string, options Options) error
|
||||
Run(ctx context.Context, filePaths []string, serviceName string, options RunOptions) error
|
||||
Validate(ctx context.Context, filePaths []string, options Options) error
|
||||
WaitForStatus(ctx context.Context, name string, status Status) <-chan WaitResult
|
||||
WaitForStatus(ctx context.Context, name string, status Status) WaitResult
|
||||
Config(ctx context.Context, filePaths []string, options Options) ([]byte, error)
|
||||
GetExistingEdgeStacks(ctx context.Context) ([]EdgeStack, error)
|
||||
}
|
||||
|
||||
+72
-75
@@ -8,7 +8,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/docker/consts"
|
||||
edgeutils "github.com/portainer/portainer/pkg/edge"
|
||||
networkingutils "github.com/portainer/portainer/pkg/networking"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
@@ -17,11 +20,8 @@ import (
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/docker/consts"
|
||||
edgeutils "github.com/portainer/portainer/pkg/edge"
|
||||
networkingutils "github.com/portainer/portainer/pkg/networking"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
func CreateDockerSnapshot(cli *client.Client) (*portainer.DockerSnapshot, error) {
|
||||
@@ -29,49 +29,39 @@ func CreateDockerSnapshot(cli *client.Client) (*portainer.DockerSnapshot, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dockerSnapshot := &portainer.DockerSnapshot{
|
||||
StackCount: 0,
|
||||
}
|
||||
dockerSnapshot := &portainer.DockerSnapshot{}
|
||||
|
||||
err := dockerSnapshotInfo(dockerSnapshot, cli)
|
||||
if err != nil {
|
||||
if err := dockerSnapshotInfo(dockerSnapshot, cli); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to snapshot engine information")
|
||||
}
|
||||
|
||||
if dockerSnapshot.Swarm {
|
||||
err = dockerSnapshotSwarmServices(dockerSnapshot, cli)
|
||||
if err != nil {
|
||||
if err := dockerSnapshotSwarmServices(dockerSnapshot, cli); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to snapshot Swarm services")
|
||||
}
|
||||
|
||||
err = dockerSnapshotNodes(dockerSnapshot, cli)
|
||||
if err != nil {
|
||||
if err := dockerSnapshotNodes(dockerSnapshot, cli); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to snapshot Swarm nodes")
|
||||
}
|
||||
}
|
||||
|
||||
err = dockerSnapshotContainers(dockerSnapshot, cli)
|
||||
if err != nil {
|
||||
if err := dockerSnapshotContainers(dockerSnapshot, cli); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to snapshot containers")
|
||||
}
|
||||
|
||||
err = dockerSnapshotImages(dockerSnapshot, cli)
|
||||
if err != nil {
|
||||
if err := dockerSnapshotImages(dockerSnapshot, cli); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to snapshot images")
|
||||
}
|
||||
|
||||
err = dockerSnapshotVolumes(dockerSnapshot, cli)
|
||||
if err != nil {
|
||||
if err := dockerSnapshotVolumes(dockerSnapshot, cli); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to snapshot volumes")
|
||||
}
|
||||
|
||||
err = dockerSnapshotNetworks(dockerSnapshot, cli)
|
||||
if err != nil {
|
||||
if err := dockerSnapshotNetworks(dockerSnapshot, cli); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to snapshot networks")
|
||||
}
|
||||
|
||||
err = dockerSnapshotVersion(dockerSnapshot, cli)
|
||||
if err != nil {
|
||||
if err := dockerSnapshotVersion(dockerSnapshot, cli); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to snapshot engine version")
|
||||
}
|
||||
|
||||
@@ -101,8 +91,7 @@ func dockerSnapshotNodes(snapshot *portainer.DockerSnapshot, cli *client.Client)
|
||||
return err
|
||||
}
|
||||
|
||||
var nanoCpus int64
|
||||
var totalMem int64
|
||||
var nanoCpus, totalMem int64
|
||||
|
||||
for _, node := range nodes {
|
||||
nanoCpus += node.Description.Resources.NanoCPUs
|
||||
@@ -149,48 +138,54 @@ func dockerSnapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Cl
|
||||
gpuUseAll := false
|
||||
|
||||
for _, container := range containers {
|
||||
if container.State == "running" {
|
||||
// Snapshot GPUs
|
||||
response, err := cli.ContainerInspect(context.Background(), container.ID)
|
||||
if err != nil {
|
||||
// Inspect a container will fail when the container runs on a different
|
||||
// Swarm node, so it is better to log the error instead of return error
|
||||
// when the Swarm mode is enabled
|
||||
if !snapshot.Swarm {
|
||||
return err
|
||||
} else {
|
||||
if !strings.Contains(err.Error(), "No such container") {
|
||||
return err
|
||||
}
|
||||
// It is common to have containers running on different Swarm nodes,
|
||||
// so we just log the error in the debug level
|
||||
log.Debug().Str("container", container.ID).Err(err).Msg("unable to inspect container in other Swarm nodes")
|
||||
}
|
||||
} else {
|
||||
var gpuOptions *_container.DeviceRequest = nil
|
||||
for _, deviceRequest := range response.HostConfig.Resources.DeviceRequests {
|
||||
if deviceRequest.Driver == "nvidia" || deviceRequest.Capabilities[0][0] == "gpu" {
|
||||
gpuOptions = &deviceRequest
|
||||
}
|
||||
}
|
||||
|
||||
if gpuOptions != nil {
|
||||
if gpuOptions.Count == -1 {
|
||||
gpuUseAll = true
|
||||
}
|
||||
|
||||
for _, id := range gpuOptions.DeviceIDs {
|
||||
gpuUseSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range container.Labels {
|
||||
if k == consts.ComposeStackNameLabel {
|
||||
stacks[v] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if container.State != "running" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Snapshot GPUs
|
||||
response, err := cli.ContainerInspect(context.Background(), container.ID)
|
||||
if err != nil && !snapshot.Swarm {
|
||||
return err
|
||||
} else if err != nil {
|
||||
// Inspect a container will fail when the container runs on a different
|
||||
// Swarm node, so it is better to log the error instead of return error
|
||||
// when the Swarm mode is enabled
|
||||
if !strings.Contains(err.Error(), "No such container") {
|
||||
return err
|
||||
}
|
||||
|
||||
// It is common to have containers running on different Swarm nodes,
|
||||
// so we just log the error in the debug level
|
||||
log.Debug().Str("container", container.ID).Err(err).Msg("unable to inspect container in other Swarm nodes")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
var gpuOptions *_container.DeviceRequest
|
||||
|
||||
for _, deviceRequest := range response.HostConfig.Resources.DeviceRequests {
|
||||
if deviceRequest.Driver == "nvidia" || deviceRequest.Capabilities[0][0] == "gpu" {
|
||||
gpuOptions = &deviceRequest
|
||||
}
|
||||
}
|
||||
|
||||
if gpuOptions == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if gpuOptions.Count == -1 {
|
||||
gpuUseAll = true
|
||||
}
|
||||
|
||||
for _, id := range gpuOptions.DeviceIDs {
|
||||
gpuUseSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
gpuUseList := make([]string, 0, len(gpuUseSet))
|
||||
@@ -260,6 +255,7 @@ func dockerSnapshotVersion(snapshot *portainer.DockerSnapshot, cli *client.Clien
|
||||
|
||||
snapshot.SnapshotRaw.Version = version
|
||||
snapshot.IsPodman = isPodman(version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -273,21 +269,22 @@ func DockerSnapshotDiagnostics(cli *client.Client, edgeKey string) (*portainer.D
|
||||
},
|
||||
}
|
||||
|
||||
err := dockerSnapshotContainerErrorLogs(snapshot, cli, containerID)
|
||||
if err != nil {
|
||||
if err := dockerSnapshotContainerErrorLogs(snapshot, cli, containerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if edgeKey != "" {
|
||||
url, err := edgeutils.GetPortainerURLFromEdgeKey(edgeKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get portainer URL from edge key: %w", err)
|
||||
}
|
||||
|
||||
snapshot.DiagnosticsData.DNS["edge-to-portainer"] = networkingutils.ProbeDNSConnection(url)
|
||||
snapshot.DiagnosticsData.Telnet["edge-to-portainer"] = networkingutils.ProbeTelnetConnection(url)
|
||||
if edgeKey == "" {
|
||||
return snapshot.DiagnosticsData, nil
|
||||
}
|
||||
|
||||
url, err := edgeutils.GetPortainerURLFromEdgeKey(edgeKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get portainer URL from edge key: %w", err)
|
||||
}
|
||||
|
||||
snapshot.DiagnosticsData.DNS["edge-to-portainer"] = networkingutils.ProbeDNSConnection(url)
|
||||
snapshot.DiagnosticsData.Telnet["edge-to-portainer"] = networkingutils.ProbeTelnetConnection(url)
|
||||
|
||||
return snapshot.DiagnosticsData, nil
|
||||
}
|
||||
|
||||
@@ -310,8 +307,7 @@ func dockerSnapshotContainerErrorLogs(snapshot *portainer.DockerSnapshot, cli *c
|
||||
defer rd.Close()
|
||||
|
||||
var stdOut, stdErr bytes.Buffer
|
||||
_, err = stdcopy.StdCopy(&stdErr, &stdOut, rd)
|
||||
if err != nil {
|
||||
if _, err := stdcopy.StdCopy(&stdErr, &stdOut, rd); err != nil {
|
||||
return fmt.Errorf("failed to copy error logs: %w", err)
|
||||
}
|
||||
|
||||
@@ -334,6 +330,7 @@ func isPodman(version types.Version) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user