Compare commits

...

22 Commits

Author SHA1 Message Date
Ali
d16462195f chore(portainer): bump version to 2.26.0 (#302) 2025-01-15 11:36:18 +13:00
Yajith Dayarathna
55c98912ed feat(omni): support for omni [R8S-75] (#105)
Co-authored-by: stevensbkang <skan070@gmail.com>
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2025-01-13 17:06:10 +13:00
Ali
45bd7984b0 fit(jobs): remove redundant checkboxes in executions datatable [r8s-182] (#295) 2025-01-12 18:24:22 +13:00
andres-portainer
1ed9a0106e feat(edge): optimize Edge Stack retrieval BE-11555 (#294) 2025-01-10 16:44:19 -03:00
LP B
f8b2ee8c0d fix(app/edge-stack): local filesystem path is not retained (#292) 2025-01-10 18:20:44 +01:00
Steven Kang
d32b0f8b7e feat(kubernetes): support for jobs and cron jobs - r8s-182 (#260)
Co-authored-by: James Carppe <85850129+jamescarppe@users.noreply.github.com>
Co-authored-by: Anthony Lapenna <anthony.lapenna@portainer.io>
Co-authored-by: andres-portainer <91705312+andres-portainer@users.noreply.github.com>
Co-authored-by: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
Co-authored-by: testA113 <aliharriss1995@gmail.com>
2025-01-10 13:21:27 +13:00
andres-portainer
24fdb1f600 fix(libstack): redirect the Docker and Compose logging to zerolog BE-11518 (#289) 2025-01-08 16:26:04 -03:00
Oscar Zhou
4010174f66 fix(docker/volume): failed to list volume before snapshot is created [BE-11544] (#286) 2025-01-08 09:45:13 +13:00
andres-portainer
e2b812a611 fix(edgestacks): check the version of the edge stack before updating the status BE-11488 (#287) 2025-01-07 17:31:57 -03:00
andres-portainer
d72b3a9ba2 feat(edgestacks): optimize the Edge Stack status update endpoint BE-11539 (#279) 2025-01-06 15:39:24 -03:00
LP B
85f52d2574 feat(app/stack): ability to prune volumes on stack/edge stack delete (#232)
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2025-01-01 10:44:49 +13:00
andres-portainer
33ea22c0a9 feat(ssl): improve caching behavior BE-11527 (#273) 2024-12-30 11:10:13 -03:00
andres-portainer
0d52f9dd0e feat(async): avoid sending CSRF token for async edge polling requests BE-1152 (#272) 2024-12-30 10:58:44 -03:00
andres-portainer
3caffe1e85 feat(async): filter out Docker snapshot diffs without meaningful changes BE-11527 (#265) 2024-12-26 18:45:20 -03:00
Oscar Zhou
87b8dd61c3 fix: replace strings.ToLower with strings.EqualFold [BE-11524] (#263) 2024-12-24 11:15:16 +13:00
andres-portainer
ad77cd195c fix(docker): fix a data race in the Docker transport BE-10873 (#255) 2024-12-23 09:54:11 -03:00
James Carppe
eb2a754580 Update bug report template for 2.21.5 / 2.25.1 (#261) 2024-12-20 14:39:33 +13:00
Steven Kang
9258db58db feat(auth): add 30m session timeout - r8s-178 (#259) 2024-12-20 10:49:13 +13:00
andres-portainer
8d1c90f912 fix(platform): fix a data race in GetPlatform() BE-11522 (#253) 2024-12-19 09:37:50 -03:00
Steven Kang
1c62bd6ca5 fix: security - CVE-2024-45337 - portainer-suite develop (#247) 2024-12-19 10:55:34 +13:00
andres-portainer
13317ec43c feat(stacks): simplify WaitForStatus() BE-11505 (#241) 2024-12-17 16:25:49 -03:00
James Carppe
35dcb5ca46 Update bug report template for 2.25.0 (#245) 2024-12-16 13:53:15 +13:00
89 changed files with 2317 additions and 433 deletions

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
// @id GetKubernetesCronJobs
// @summary Get a list of kubernetes Cron Jobs
// @description Get a list of kubernetes Cron Jobs that the user has access to.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @success 200 {array} models.K8sCronJob "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier."
// @failure 500 "Server error occurred while attempting to retrieve the list of Cron Jobs."
// @router /kubernetes/{id}/cron_jobs [get]
func (handler *Handler) getAllKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to prepare kube client")
return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr)
}
cronJobs, err := cli.GetCronJobs("")
if err != nil {
log.Error().Err(err).Str("context", "GetAllKubernetesCronJobs").Msg("Unable to fetch Cron Jobs across all namespaces")
return httperror.InternalServerError("unable to fetch Cron Jobs. Error: ", err)
}
return response.JSON(w, cronJobs)
}
// @id DeleteCronJobs
// @summary Delete Cron Jobs
// @description Delete the provided list of Cron Jobs.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sCronJobDeleteRequests true "A map where the key is the namespace and the value is an array of Cron Jobs to delete"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service account."
// @failure 500 "Server error occurred while attempting to delete Cron Jobs."
// @router /kubernetes/{id}/cron_jobs/delete [POST]
func (handler *Handler) deleteKubernetesCronJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sCronJobDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := handler.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
err = cli.DeleteCronJobs(payload)
if err != nil {
return httperror.InternalServerError("Unable to delete Cron Jobs", err)
}
return response.Empty(w)
}

View File

@@ -55,6 +55,10 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/applications/count", httperror.LoggerHandler(h.getAllKubernetesApplicationsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet)
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs", httperror.LoggerHandler(h.getAllKubernetesCronJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/cron_jobs/delete", httperror.LoggerHandler(h.deleteKubernetesCronJobs)).Methods(http.MethodPost)
endpointRouter.Handle("/jobs", httperror.LoggerHandler(h.getAllKubernetesJobs)).Methods(http.MethodGet)
endpointRouter.Handle("/jobs/delete", httperror.LoggerHandler(h.deleteKubernetesJobs)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet)
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)

View File

@@ -0,0 +1,85 @@
package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
// @id GetKubernetesJobs
// @summary Get a list of kubernetes Jobs
// @description Get a list of kubernetes Jobs that the user has access to.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @produce json
// @param id path int true "Environment identifier"
// @param includeCronJobChildren query bool false "Whether to include Jobs that have a cronjob owner"
// @success 200 {array} models.K8sJob "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier."
// @failure 500 "Server error occurred while attempting to retrieve the list of Jobs."
// @router /kubernetes/{id}/jobs [get]
func (handler *Handler) getAllKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
includeCronJobChildren, err := request.RetrieveBooleanQueryParameter(r, "includeCronJobChildren", true)
if err != nil {
log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Invalid query parameter includeCronJobChildren")
return httperror.BadRequest("an error occurred during the GetAllKubernetesJobs operation, invalid query parameter includeCronJobChildren. Error: ", err)
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetAllKubernetesJobs").Msg("Unable to prepare kube client")
return httperror.InternalServerError("unable to prepare kube client. Error: ", httpErr)
}
jobs, err := cli.GetJobs("", includeCronJobChildren)
if err != nil {
log.Error().Err(err).Str("context", "GetAllKubernetesJobs").Msg("Unable to fetch Jobs across all namespaces")
return httperror.InternalServerError("unable to fetch Jobs. Error: ", err)
}
return response.JSON(w, jobs)
}
// @id DeleteJobs
// @summary Delete Jobs
// @description Delete the provided list of Jobs.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sJobDeleteRequests true "A map where the key is the namespace and the value is an array of Jobs to delete"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service account."
// @failure 500 "Server error occurred while attempting to delete Jobs."
// @router /kubernetes/{id}/jobs/delete [POST]
func (handler *Handler) deleteKubernetesJobs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sJobDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := handler.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
err = cli.DeleteJobs(payload)
if err != nil {
return httperror.InternalServerError("Unable to delete Jobs", err)
}
return response.Empty(w)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -370,6 +370,7 @@ type (
Error string
// EE only feature
RollbackTo *int
Version int `json:"Version,omitempty"`
}
// EdgeStackStatusType represents an edge stack status type
@@ -1395,6 +1396,13 @@ type (
Prune bool
}
ComposeDownOptions struct {
// RemoveVolumes will remove the named volumes declared in the compose file
// and anonymous volumes attached to the stack's containers
// Drives `docker compose down --volumes`
RemoveVolumes bool
}
ComposeRunOptions struct {
ComposeOptions
@@ -1628,7 +1636,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.25.0"
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

View File

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

View File

@@ -21,6 +21,7 @@ import { RolesView } from '@/react/kubernetes/more-resources/RolesView';
import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView';
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
export const viewsModule = angular
.module('portainer.kubernetes.react.views', [])
@@ -89,6 +90,10 @@ export const viewsModule = angular
'kubernetesConsoleView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ConsoleView))), [])
)
.component(
'jobsView',
r2a(withUIRouter(withReactQuery(withCurrentUser(JobsView))), [])
)
.component(
'serviceAccountsView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ServiceAccountsView))), [])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { CronJobsDatatable } from './CronJobsDatatable';

View File

@@ -0,0 +1,6 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
export const queryKeys = {
list: (environmentId: EnvironmentId) =>
['environments', environmentId, 'kubernetes', 'cronJobs'] as const,
};

View File

@@ -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');
}
}

View File

@@ -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`);
}
}

View File

@@ -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[];
};

View File

@@ -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;
}
}

View File

@@ -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>
),
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
];

View File

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

View File

@@ -0,0 +1,32 @@
import { Row } from '@tanstack/react-table';
import { filterHOC } from '@@/datatables/Filter';
import { Link } from '@@/Link';
import { 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 ?? ''),
});

View File

@@ -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(),
}
);

View File

@@ -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);
}

View File

@@ -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>
)}
</>
);
}

View File

@@ -0,0 +1 @@
export { JobsDatatable } from './JobsDatatable';

View File

@@ -0,0 +1,6 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
export const queryKeys = {
list: (environmentId: EnvironmentId) =>
['environments', environmentId, 'kubernetes', 'jobs'] as const,
};

View File

@@ -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`);
}
}

View File

@@ -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');
}
}

View File

@@ -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;
};

View File

@@ -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>
</>
</>
);
}

View File

@@ -0,0 +1 @@
export { JobsView } from './JobsView';

View File

@@ -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">

View File

@@ -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 };
}

View File

@@ -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"

View File

@@ -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[];
};

View File

@@ -1,6 +1,6 @@
{
"docker": "v27.1.2",
"helm": "v3.15.4",
"kubectl": "v1.31.0",
"helm": "v3.16.4",
"kubectl": "v1.31.4",
"mingit": "2.46.0.1"
}

View File

@@ -10,11 +10,10 @@ PLATFORM=$1
ARCH=$2
KUBECTL_VERSION=$3
if [[ ${PLATFORM} == "windows" ]]; then
wget --tries=3 --waitretry=30 --quiet -O "dist/kubectl.exe" "https://storage.googleapis.com/kubernetes-release/release/${KUBECTL_VERSION}/bin/windows/amd64/kubectl.exe"
wget --tries=3 --waitretry=30 --quiet -O "dist/kubectl.exe" "https://dl.k8s.io/${KUBECTL_VERSION}/bin/windows/amd64/kubectl.exe"
chmod +x "dist/kubectl.exe"
else
wget --tries=3 --waitretry=30 --quiet -O "dist/kubectl" "https://storage.googleapis.com/kubernetes-release/release/${KUBECTL_VERSION}/bin/${PLATFORM}/${ARCH}/kubectl"
wget --tries=3 --waitretry=30 --quiet -O "dist/kubectl" "https://dl.k8s.io/${KUBECTL_VERSION}/bin/${PLATFORM}/${ARCH}/kubectl"
chmod +x "dist/kubectl"
fi

16
go.mod
View File

@@ -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,15 +42,16 @@ 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
go.etcd.io/bbolt v1.3.11
golang.org/x/crypto v0.27.0
golang.org/x/crypto v0.31.0
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0
golang.org/x/mod v0.21.0
golang.org/x/oauth2 v0.23.0
golang.org/x/sync v0.9.0
golang.org/x/sync v0.10.0
gopkg.in/alecthomas/kingpin.v2 v2.2.6
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.29.2
@@ -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
@@ -243,9 +243,9 @@ require (
go.opentelemetry.io/proto/otlp v1.1.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/term v0.24.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.6.0 // indirect
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect

28
go.sum
View File

@@ -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=
@@ -699,8 +699,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -736,8 +736,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -768,16 +768,16 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -785,8 +785,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "2.25.0",
"version": "2.26.0",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}