Compare commits
44 Commits
2.25.1
...
2.27.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27cbc3818e | ||
|
|
e943aa8f03 | ||
|
|
17a4750d8e | ||
|
|
7d18c22aa1 | ||
|
|
c80cc6e268 | ||
|
|
b30a1b5250 | ||
|
|
b753371700 | ||
|
|
3ca5ab180f | ||
|
|
4971f5510c | ||
|
|
20fa7e508d | ||
|
|
ebffc340d9 | ||
|
|
9a86737caa | ||
|
|
d35d8a7307 | ||
|
|
701ff5d6bc | ||
|
|
9044b25a23 | ||
|
|
7f089fab86 | ||
|
|
a259c28678 | ||
|
|
db48da185a | ||
|
|
cab667c23b | ||
|
|
154ca9f1b1 | ||
|
|
2abe40b786 | ||
|
|
6be2420b32 | ||
|
|
9405cc0e04 | ||
|
|
55c98912ed | ||
|
|
45bd7984b0 | ||
|
|
1ed9a0106e | ||
|
|
f8b2ee8c0d | ||
|
|
d32b0f8b7e | ||
|
|
24fdb1f600 | ||
|
|
4010174f66 | ||
|
|
e2b812a611 | ||
|
|
d72b3a9ba2 | ||
|
|
85f52d2574 | ||
|
|
33ea22c0a9 | ||
|
|
0d52f9dd0e | ||
|
|
3caffe1e85 | ||
|
|
87b8dd61c3 | ||
|
|
ad77cd195c | ||
|
|
eb2a754580 | ||
|
|
9258db58db | ||
|
|
8d1c90f912 | ||
|
|
1c62bd6ca5 | ||
|
|
13317ec43c | ||
|
|
35dcb5ca46 |
7
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
7
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -95,10 +95,15 @@ body:
|
||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.26.1'
|
||||
- '2.26.0'
|
||||
- '2.25.1'
|
||||
- '2.25.0'
|
||||
- '2.24.1'
|
||||
- '2.24.0'
|
||||
- '2.23.0'
|
||||
- '2.22.0'
|
||||
- '2.21.5'
|
||||
- '2.21.4'
|
||||
- '2.21.3'
|
||||
- '2.21.2'
|
||||
@@ -118,8 +123,6 @@ body:
|
||||
- '2.18.3'
|
||||
- '2.18.2'
|
||||
- '2.18.1'
|
||||
- '2.17.1'
|
||||
- '2.17.0'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -19,7 +19,5 @@ func Confirm(message string) (bool, error) {
|
||||
}
|
||||
|
||||
answer = strings.ReplaceAll(answer, "\n", "")
|
||||
answer = strings.ToLower(answer)
|
||||
|
||||
return answer == "y" || answer == "yes", nil
|
||||
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes"), nil
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type Service struct {
|
||||
connection portainer.Connection
|
||||
idxVersion map[portainer.EdgeStackID]int
|
||||
mu sync.RWMutex
|
||||
cacheInvalidationFn func(portainer.EdgeStackID)
|
||||
cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)
|
||||
}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
@@ -23,7 +23,7 @@ func (service *Service) BucketName() string {
|
||||
}
|
||||
|
||||
// NewService creates a new instance of a service.
|
||||
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.EdgeStackID)) (*Service, error) {
|
||||
func NewService(connection portainer.Connection, cacheInvalidationFn func(portainer.Transaction, portainer.EdgeStackID)) (*Service, error) {
|
||||
err := connection.SetServiceName(BucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -36,7 +36,7 @@ func NewService(connection portainer.Connection, cacheInvalidationFn func(portai
|
||||
}
|
||||
|
||||
if s.cacheInvalidationFn == nil {
|
||||
s.cacheInvalidationFn = func(portainer.EdgeStackID) {}
|
||||
s.cacheInvalidationFn = func(portainer.Transaction, portainer.EdgeStackID) {}
|
||||
}
|
||||
|
||||
es, err := s.EdgeStacks()
|
||||
@@ -106,7 +106,7 @@ func (service *Service) Create(id portainer.EdgeStackID, edgeStack *portainer.Ed
|
||||
|
||||
service.mu.Lock()
|
||||
service.idxVersion[id] = edgeStack.Version
|
||||
service.cacheInvalidationFn(id)
|
||||
service.cacheInvalidationFn(service.connection, id)
|
||||
service.mu.Unlock()
|
||||
|
||||
return nil
|
||||
@@ -125,7 +125,7 @@ func (service *Service) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *por
|
||||
}
|
||||
|
||||
service.idxVersion[ID] = edgeStack.Version
|
||||
service.cacheInvalidationFn(ID)
|
||||
service.cacheInvalidationFn(service.connection, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func (service *Service) UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc
|
||||
updateFunc(edgeStack)
|
||||
|
||||
service.idxVersion[ID] = edgeStack.Version
|
||||
service.cacheInvalidationFn(ID)
|
||||
service.cacheInvalidationFn(service.connection, ID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error {
|
||||
|
||||
delete(service.idxVersion, ID)
|
||||
|
||||
service.cacheInvalidationFn(ID)
|
||||
service.cacheInvalidationFn(service.connection, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -44,8 +44,7 @@ func (service ServiceTx) EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeSta
|
||||
var stack portainer.EdgeStack
|
||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||
|
||||
err := service.tx.GetObject(BucketName, identifier, &stack)
|
||||
if err != nil {
|
||||
if err := service.tx.GetObject(BucketName, identifier, &stack); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -65,18 +64,17 @@ func (service ServiceTx) EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
|
||||
func (service ServiceTx) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error {
|
||||
edgeStack.ID = id
|
||||
|
||||
err := service.tx.CreateObjectWithId(
|
||||
if err := service.tx.CreateObjectWithId(
|
||||
BucketName,
|
||||
int(edgeStack.ID),
|
||||
edgeStack,
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.idxVersion[id] = edgeStack.Version
|
||||
service.service.cacheInvalidationFn(id)
|
||||
service.service.cacheInvalidationFn(service.tx, id)
|
||||
service.service.mu.Unlock()
|
||||
|
||||
return nil
|
||||
@@ -89,13 +87,12 @@ func (service ServiceTx) UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *po
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||
|
||||
err := service.tx.UpdateObject(BucketName, identifier, edgeStack)
|
||||
if err != nil {
|
||||
if err := service.tx.UpdateObject(BucketName, identifier, edgeStack); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.service.idxVersion[ID] = edgeStack.Version
|
||||
service.service.cacheInvalidationFn(ID)
|
||||
service.service.cacheInvalidationFn(service.tx, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -119,14 +116,13 @@ func (service ServiceTx) DeleteEdgeStack(ID portainer.EdgeStackID) error {
|
||||
|
||||
identifier := service.service.connection.ConvertToKey(int(ID))
|
||||
|
||||
err := service.tx.DeleteObject(BucketName, identifier)
|
||||
if err != nil {
|
||||
if err := service.tx.DeleteObject(BucketName, identifier); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(service.service.idxVersion, ID)
|
||||
|
||||
service.service.cacheInvalidationFn(ID)
|
||||
service.service.cacheInvalidationFn(service.tx, ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package endpointrelation
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
@@ -13,9 +15,11 @@ const BucketName = "endpoint_relations"
|
||||
|
||||
// Service represents a service for managing environment(endpoint) relation data.
|
||||
type Service struct {
|
||||
connection portainer.Connection
|
||||
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
connection portainer.Connection
|
||||
updateStackFn func(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
updateStackFnTx func(tx portainer.Transaction, ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||
endpointRelationsCache []portainer.EndpointRelation
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
@@ -76,6 +80,10 @@ func (service *Service) Create(endpointRelation *portainer.EndpointRelation) err
|
||||
err := service.connection.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
|
||||
cache.Del(endpointRelation.EndpointID)
|
||||
|
||||
service.mu.Lock()
|
||||
service.endpointRelationsCache = nil
|
||||
service.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -92,6 +100,10 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
|
||||
updatedRelationState, _ := service.EndpointRelation(endpointID)
|
||||
|
||||
service.mu.Lock()
|
||||
service.endpointRelationsCache = nil
|
||||
service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
|
||||
|
||||
return nil
|
||||
@@ -108,27 +120,15 @@ func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID)
|
||||
return err
|
||||
}
|
||||
|
||||
service.mu.Lock()
|
||||
service.endpointRelationsCache = nil
|
||||
service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
|
||||
rels, err := service.EndpointRelations()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
|
||||
return
|
||||
}
|
||||
|
||||
for _, rel := range rels {
|
||||
for id := range rel.EdgeStacks {
|
||||
if edgeStackID == id {
|
||||
cache.Del(rel.EndpointID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
|
||||
relations, _ := service.EndpointRelations()
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ func (service ServiceTx) Create(endpointRelation *portainer.EndpointRelation) er
|
||||
err := service.tx.CreateObjectWithId(BucketName, int(endpointRelation.EndpointID), endpointRelation)
|
||||
cache.Del(endpointRelation.EndpointID)
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -61,6 +65,10 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
||||
|
||||
updatedRelationState, _ := service.EndpointRelation(endpointID)
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(previousRelationState, updatedRelationState)
|
||||
|
||||
return nil
|
||||
@@ -77,27 +85,44 @@ func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID)
|
||||
return err
|
||||
}
|
||||
|
||||
service.service.mu.Lock()
|
||||
service.service.endpointRelationsCache = nil
|
||||
service.service.mu.Unlock()
|
||||
|
||||
service.updateEdgeStacksAfterRelationChange(deletedRelation, nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) InvalidateEdgeCacheForEdgeStack(edgeStackID portainer.EdgeStackID) {
|
||||
rels, err := service.EndpointRelations()
|
||||
rels, err := service.cachedEndpointRelations()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("cannot retrieve endpoint relations")
|
||||
return
|
||||
}
|
||||
|
||||
for _, rel := range rels {
|
||||
for id := range rel.EdgeStacks {
|
||||
if edgeStackID == id {
|
||||
cache.Del(rel.EndpointID)
|
||||
}
|
||||
if _, ok := rel.EdgeStacks[edgeStackID]; ok {
|
||||
cache.Del(rel.EndpointID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service ServiceTx) cachedEndpointRelations() ([]portainer.EndpointRelation, error) {
|
||||
service.service.mu.Lock()
|
||||
defer service.service.mu.Unlock()
|
||||
|
||||
if service.service.endpointRelationsCache == nil {
|
||||
var err error
|
||||
service.service.endpointRelationsCache, err = service.EndpointRelations()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return service.service.endpointRelationsCache, nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationState *portainer.EndpointRelation, updatedRelationState *portainer.EndpointRelation) {
|
||||
relations, _ := service.EndpointRelations()
|
||||
|
||||
@@ -133,6 +158,7 @@ func (service ServiceTx) updateEdgeStacksAfterRelationChange(previousRelationSta
|
||||
}
|
||||
|
||||
numDeployments := 0
|
||||
|
||||
for _, r := range relations {
|
||||
for sId, enabled := range r.EdgeStacks {
|
||||
if enabled && sId == refStackId {
|
||||
|
||||
@@ -100,7 +100,9 @@ func (store *Store) initServices() error {
|
||||
}
|
||||
store.EndpointRelationService = endpointRelationService
|
||||
|
||||
edgeStackService, err := edgestack.NewService(store.connection, endpointRelationService.InvalidateEdgeCacheForEdgeStack)
|
||||
edgeStackService, err := edgestack.NewService(store.connection, func(tx portainer.Transaction, ID portainer.EdgeStackID) {
|
||||
endpointRelationService.Tx(tx).InvalidateEdgeCacheForEdgeStack(ID)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -610,7 +610,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.25.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.27.0-rc1",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.25.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.27.0-rc1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -64,9 +64,14 @@ type (
|
||||
|
||||
DeployerOptionsPayload struct {
|
||||
// Prune is a flag indicating if the agent must prune the containers or not when creating/updating an edge stack
|
||||
// This flag drives docker compose `--remove-orphans` and docker stack `--prune` options
|
||||
// This flag drives `docker compose up --remove-orphans` and `docker stack up --prune` options
|
||||
// Used only for EE
|
||||
Prune bool
|
||||
// RemoveVolumes is a flag indicating if the agent must remove the named volumes declared
|
||||
// in the compose file and anonymouse volumes attached to containers
|
||||
// This flag drives `docker compose down --volumes` option
|
||||
// Used only for EE
|
||||
RemoveVolumes bool
|
||||
}
|
||||
|
||||
// RegistryCredentials holds the credentials for a Docker registry.
|
||||
|
||||
@@ -44,13 +44,13 @@ func (service *Service) executeDeviceAction(configuration portainer.OpenAMTConfi
|
||||
}
|
||||
|
||||
func parseAction(actionRaw string) (portainer.PowerState, error) {
|
||||
switch strings.ToLower(actionRaw) {
|
||||
case "power on":
|
||||
if strings.EqualFold(actionRaw, "power on") {
|
||||
return powerOnState, nil
|
||||
case "power off":
|
||||
} else if strings.EqualFold(actionRaw, "power off") {
|
||||
return powerOffState, nil
|
||||
case "restart":
|
||||
} else if strings.EqualFold(actionRaw, "restart") {
|
||||
return restartState, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unsupported device action %s", actionRaw)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package edgestacks
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
@@ -52,10 +53,14 @@ func (handler *Handler) deleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups)
|
||||
if err != nil {
|
||||
if err := handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups); err != nil {
|
||||
return httperror.InternalServerError("Unable to delete edge stack", err)
|
||||
}
|
||||
|
||||
stackFolder := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(edgeStack.ID)))
|
||||
if err := handler.FileService.RemoveDirectory(stackFolder); err != nil {
|
||||
return httperror.InternalServerError("Unable to remove edge stack project folder", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
@@ -101,3 +103,52 @@ func TestDeleteInvalidEdgeStack(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
edgeGroup := createEdgeGroup(t, handler.DataStore)
|
||||
|
||||
payload := edgeStackFromStringPayload{
|
||||
Name: "test-stack",
|
||||
DeploymentType: portainer.EdgeStackDeploymentCompose,
|
||||
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
|
||||
StackFileContent: "version: '3.7'\nservices:\n test:\n image: test",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
|
||||
t.Fatal("error encoding payload:", err)
|
||||
}
|
||||
|
||||
// Create
|
||||
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf)
|
||||
if err != nil {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code)
|
||||
}
|
||||
|
||||
assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
|
||||
|
||||
// Delete
|
||||
if req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil); err != nil {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code)
|
||||
}
|
||||
|
||||
assert.NoDirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *h
|
||||
|
||||
stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
|
||||
if err != nil {
|
||||
return handler.handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
fileName := stack.EntryPoint
|
||||
|
||||
@@ -30,7 +30,7 @@ func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if err != nil {
|
||||
return handler.handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
return response.JSON(w, edgeStack)
|
||||
|
||||
@@ -63,7 +63,7 @@ func (handler *Handler) edgeStackStatusDelete(w http.ResponseWriter, r *http.Req
|
||||
func (handler *Handler) deleteEdgeStackStatus(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, endpoint *portainer.Endpoint) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
||||
return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
environmentStatus, ok := stack.Status[endpoint.ID]
|
||||
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -20,6 +21,7 @@ type updateStatusPayload struct {
|
||||
Status *portainer.EdgeStackStatusType
|
||||
EndpointID portainer.EndpointID
|
||||
Time int64
|
||||
Version int
|
||||
}
|
||||
|
||||
func (payload *updateStatusPayload) Validate(r *http.Request) error {
|
||||
@@ -67,11 +69,21 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||
return httperror.BadRequest("Invalid request payload", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, payload.EndpointID))
|
||||
}
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
stack, err = handler.updateEdgeStackStatus(tx, r, portainer.EdgeStackID(stackID), payload)
|
||||
return err
|
||||
}); err != nil {
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(payload.EndpointID)
|
||||
if err != nil {
|
||||
return handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
|
||||
}
|
||||
|
||||
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
updateFn := func(stack *portainer.EdgeStack) (*portainer.EdgeStack, error) {
|
||||
return handler.updateEdgeStackStatus(stack, endpoint, r, stack.ID, payload)
|
||||
}
|
||||
|
||||
stack, err := handler.stackCoordinator.UpdateStatus(r, portainer.EdgeStackID(stackID), updateFn)
|
||||
if err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr
|
||||
@@ -80,32 +92,16 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||
return httperror.InternalServerError("Unexpected error", err)
|
||||
}
|
||||
|
||||
if ok, _ := strconv.ParseBool(r.Header.Get("X-Portainer-No-Body")); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
// skip error because agent tries to report on deleted stack
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Int("stackID", int(stackID)).
|
||||
Int("status", int(*payload.Status)).
|
||||
Msg("Unable to find a stack inside the database, skipping error")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w. Environment ID: %d", err, payload.EndpointID)
|
||||
}
|
||||
|
||||
endpoint, err := tx.Endpoint().Endpoint(payload.EndpointID)
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
|
||||
}
|
||||
|
||||
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
|
||||
return nil, httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
|
||||
func (handler *Handler) updateEdgeStackStatus(stack *portainer.EdgeStack, endpoint *portainer.Endpoint, r *http.Request, stackID portainer.EdgeStackID, payload updateStatusPayload) (*portainer.EdgeStack, error) {
|
||||
if payload.Version > 0 && payload.Version < stack.Version {
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
status := *payload.Status
|
||||
@@ -123,10 +119,6 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
|
||||
|
||||
updateEnvStatus(payload.EndpointID, stack, deploymentStatus)
|
||||
|
||||
if err := tx.EdgeStack().UpdateEdgeStack(stackID, stack); err != nil {
|
||||
return nil, handler.handlerDBErr(fmt.Errorf("unable to update Edge stack to the database: %w. Environment name: %s", err, endpoint.Name), "unable to update Edge stack")
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
@@ -145,7 +137,11 @@ func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeSt
|
||||
}
|
||||
}
|
||||
|
||||
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
|
||||
if containsStatus := slices.ContainsFunc(environmentStatus.Status, func(e portainer.EdgeStackDeploymentStatus) bool {
|
||||
return e.Type == deploymentStatus.Type
|
||||
}); !containsStatus {
|
||||
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
|
||||
}
|
||||
|
||||
stack.Status[environmentId] = environmentStatus
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type statusRequest struct {
|
||||
respCh chan statusResponse
|
||||
stackID portainer.EdgeStackID
|
||||
updateFn statusUpdateFn
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Stack *portainer.EdgeStack
|
||||
Error error
|
||||
}
|
||||
|
||||
type statusUpdateFn func(*portainer.EdgeStack) (*portainer.EdgeStack, error)
|
||||
|
||||
type EdgeStackStatusUpdateCoordinator struct {
|
||||
updateCh chan statusRequest
|
||||
dataStore dataservices.DataStore
|
||||
}
|
||||
|
||||
var errAnotherStackUpdateInProgress = errors.New("another stack update is in progress")
|
||||
|
||||
func NewEdgeStackStatusUpdateCoordinator(dataStore dataservices.DataStore) *EdgeStackStatusUpdateCoordinator {
|
||||
return &EdgeStackStatusUpdateCoordinator{
|
||||
updateCh: make(chan statusRequest),
|
||||
dataStore: dataStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *EdgeStackStatusUpdateCoordinator) Start() {
|
||||
for {
|
||||
c.loop()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *EdgeStackStatusUpdateCoordinator) loop() {
|
||||
u := <-c.updateCh
|
||||
|
||||
respChs := []chan statusResponse{u.respCh}
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
|
||||
err := c.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
// 1. Load the edge stack
|
||||
var err error
|
||||
|
||||
stack, err = loadEdgeStack(tx, u.stackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Mutate the edge stack opportunistically until there are no more pending updates
|
||||
for {
|
||||
stack, err = u.updateFn(stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if m, ok := c.getNextUpdate(stack.ID); ok {
|
||||
u = m
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
respChs = append(respChs, u.respCh)
|
||||
}
|
||||
|
||||
// 3. Save the changes back to the database
|
||||
if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil {
|
||||
return handlerDBErr(fmt.Errorf("unable to update Edge stack: %w.", err), "Unable to persist the stack changes inside the database")
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
// 4. Send back the responses
|
||||
for _, ch := range respChs {
|
||||
ch <- statusResponse{Stack: stack, Error: err}
|
||||
}
|
||||
}
|
||||
|
||||
func loadEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
// Skip the error when the agent tries to update the status on a deleted stack
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Int("stackID", int(stackID)).
|
||||
Msg("Unable to find a stack inside the database, skipping error")
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unable to retrieve Edge stack from the database: %w.", err)
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
func (c *EdgeStackStatusUpdateCoordinator) getNextUpdate(stackID portainer.EdgeStackID) (statusRequest, bool) {
|
||||
for {
|
||||
select {
|
||||
case u := <-c.updateCh:
|
||||
// Discard the update and let the agent retry
|
||||
if u.stackID != stackID {
|
||||
u.respCh <- statusResponse{Error: errAnotherStackUpdateInProgress}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return u, true
|
||||
|
||||
default:
|
||||
return statusRequest{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *EdgeStackStatusUpdateCoordinator) UpdateStatus(r *http.Request, stackID portainer.EdgeStackID, updateFn statusUpdateFn) (*portainer.EdgeStack, error) {
|
||||
respCh := make(chan statusResponse)
|
||||
defer close(respCh)
|
||||
|
||||
msg := statusRequest{
|
||||
respCh: respCh,
|
||||
stackID: stackID,
|
||||
updateFn: updateFn,
|
||||
}
|
||||
|
||||
select {
|
||||
case c.updateCh <- msg:
|
||||
r := <-respCh
|
||||
|
||||
return r.Stack, r.Error
|
||||
|
||||
case <-r.Context().Done():
|
||||
return nil, r.Context().Err()
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,14 @@ func setupHandler(t *testing.T) (*Handler, string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
coord := NewEdgeStackStatusUpdateCoordinator(store)
|
||||
go coord.Start()
|
||||
|
||||
handler := NewHandler(
|
||||
security.NewRequestBouncer(store, jwtService, apiKeyService),
|
||||
store,
|
||||
edgestacks.NewService(store),
|
||||
coord,
|
||||
)
|
||||
|
||||
handler.FileService = fs
|
||||
@@ -144,3 +148,15 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port
|
||||
|
||||
return edgeStack
|
||||
}
|
||||
|
||||
func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeGroup {
|
||||
edgeGroup := portainer.EdgeGroup{
|
||||
ID: 1,
|
||||
Name: "EdgeGroup 1",
|
||||
}
|
||||
|
||||
if err := store.EdgeGroup().Create(&edgeGroup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return edgeGroup
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
||||
func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, payload updateEdgeStackPayload) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
return nil, handler.handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
||||
return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
relationConfig, err := edge.FetchEndpointRelationsConfig(tx)
|
||||
|
||||
@@ -22,15 +22,17 @@ type Handler struct {
|
||||
GitService portainer.GitService
|
||||
edgeStacksService *edgestackservice.Service
|
||||
KubernetesDeployer portainer.KubernetesDeployer
|
||||
stackCoordinator *EdgeStackStatusUpdateCoordinator
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service, stackCoordinator *EdgeStackStatusUpdateCoordinator) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
DataStore: dataStore,
|
||||
edgeStacksService: edgeStacksService,
|
||||
stackCoordinator: stackCoordinator,
|
||||
}
|
||||
|
||||
h.Handle("/edge_stacks/create/{method}",
|
||||
@@ -58,10 +60,10 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
return h
|
||||
}
|
||||
|
||||
func (handler *Handler) handlerDBErr(err error, msg string) *httperror.HandlerError {
|
||||
func handlerDBErr(err error, msg string) *httperror.HandlerError {
|
||||
httpErr := httperror.InternalServerError(msg, err)
|
||||
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
httpErr.StatusCode = http.StatusNotFound
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package endpointedge
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/edge"
|
||||
@@ -13,8 +15,12 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var edgeStackSingleFlightGroup = singleflight.Group{}
|
||||
|
||||
// @summary Inspect an Edge Stack for an Environment(Endpoint)
|
||||
// @description **Access policy**: public
|
||||
// @tags edge, endpoints, edge_stacks
|
||||
@@ -42,13 +48,26 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
|
||||
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
|
||||
s, err, _ := edgeStackSingleFlightGroup.Do(strconv.Itoa(edgeStackID), func() (any, error) {
|
||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
return edgeStack, err
|
||||
})
|
||||
if err != nil {
|
||||
var httpErr *httperror.HandlerError
|
||||
if errors.As(err, &httpErr) {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment name: %s", err, endpoint.Name))
|
||||
}
|
||||
|
||||
// WARNING: this variable must not be mutated
|
||||
edgeStack := s.(*portainer.EdgeStack)
|
||||
|
||||
fileName := edgeStack.EntryPoint
|
||||
if endpointutils.IsDockerEndpoint(endpoint) {
|
||||
if fileName == "" {
|
||||
|
||||
@@ -83,7 +83,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.25.0
|
||||
// @version 2.26.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
78
api/http/handler/kubernetes/cron_job.go
Normal file
78
api/http/handler/kubernetes/cron_job.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
85
api/http/handler/kubernetes/job.go
Normal file
85
api/http/handler/kubernetes/job.go
Normal 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)
|
||||
}
|
||||
@@ -36,5 +36,9 @@ func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *ht
|
||||
return httperror.InternalServerError("Unable to retrieve registries from the database", err)
|
||||
}
|
||||
|
||||
for idx := range registries {
|
||||
hideFields(®istries[idx], false)
|
||||
}
|
||||
|
||||
return response.JSON(w, registries)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package system
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
statusutil "github.com/portainer/portainer/api/internal/nodes"
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
@@ -31,14 +32,15 @@ func (handler *Handler) systemNodesCount(w http.ResponseWriter, r *http.Request)
|
||||
return httperror.InternalServerError("Failed to get environment list", err)
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
err = snapshot.FillSnapshotData(handler.dataStore, &endpoints[i])
|
||||
if err != nil {
|
||||
var nodes int
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if err := snapshot.FillSnapshotData(handler.dataStore, &endpoint); err != nil {
|
||||
return httperror.InternalServerError("Unable to add snapshot data", err)
|
||||
}
|
||||
}
|
||||
|
||||
nodes := statusutil.NodesCount(endpoints)
|
||||
nodes += statusutil.NodesCount([]portainer.Endpoint{endpoint})
|
||||
}
|
||||
|
||||
return response.JSON(w, &nodesCountResponse{Nodes: nodes})
|
||||
}
|
||||
|
||||
36
api/http/models/kubernetes/cron_jobs.go
Normal file
36
api/http/models/kubernetes/cron_jobs.go
Normal 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
|
||||
}
|
||||
44
api/http/models/kubernetes/jobs.go
Normal file
44
api/http/models/kubernetes/jobs.go
Normal 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
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
@@ -37,6 +38,8 @@ type (
|
||||
dockerClientFactory *dockerclient.ClientFactory
|
||||
gitService portainer.GitService
|
||||
snapshotService portainer.SnapshotService
|
||||
dockerID string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// TransportParameters is used to create a new Transport
|
||||
@@ -679,9 +682,7 @@ func (transport *Transport) executeGenericResourceDeletionOperation(request *htt
|
||||
}
|
||||
|
||||
if resourceControl != nil {
|
||||
if err := transport.dataStore.ResourceControl().Delete(resourceControl.ID); err != nil {
|
||||
return response, err
|
||||
}
|
||||
err = transport.dataStore.ResourceControl().Delete(resourceControl.ID)
|
||||
}
|
||||
|
||||
return response, err
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const volumeObjectIdentifier = "ResourceID"
|
||||
@@ -50,15 +49,6 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
|
||||
|
||||
volumeData := responseObject["Volumes"].([]any)
|
||||
|
||||
if transport.snapshotService != nil {
|
||||
// Filling snapshot data can improve the performance of getVolumeResourceID
|
||||
if err = transport.snapshotService.FillSnapshotData(transport.endpoint); err != nil {
|
||||
log.Info().Err(err).
|
||||
Int("endpoint id", int(transport.endpoint.ID)).
|
||||
Msg("snapshot is not filled into the endpoint.")
|
||||
}
|
||||
}
|
||||
|
||||
for _, volumeObject := range volumeData {
|
||||
volume := volumeObject.(map[string]any)
|
||||
|
||||
@@ -147,7 +137,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
if _, err = cli.VolumeInspect(context.Background(), volumeID); err == nil {
|
||||
if _, err := cli.VolumeInspect(context.Background(), volumeID); err == nil {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusConflict,
|
||||
}, errors.New("a volume with the same name already exists")
|
||||
@@ -222,14 +212,27 @@ func (transport *Transport) getVolumeResourceID(volumeName string) (string, erro
|
||||
}
|
||||
|
||||
func (transport *Transport) getDockerID() (string, error) {
|
||||
if len(transport.endpoint.Snapshots) > 0 {
|
||||
dockerID, err := snapshot.FetchDockerID(transport.endpoint.Snapshots[0])
|
||||
// ignore err - in case of error, just generate not from snapshot
|
||||
if err == nil {
|
||||
return dockerID, nil
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
|
||||
// Local cache
|
||||
if transport.dockerID != "" {
|
||||
return transport.dockerID, nil
|
||||
}
|
||||
|
||||
// Snapshot cache
|
||||
if transport.snapshotService != nil {
|
||||
endpoint := portainer.Endpoint{ID: transport.endpoint.ID}
|
||||
|
||||
if err := transport.snapshotService.FillSnapshotData(&endpoint); err == nil && len(endpoint.Snapshots) > 0 {
|
||||
if dockerID, err := snapshot.FetchDockerID(endpoint.Snapshots[0]); err == nil {
|
||||
transport.dockerID = dockerID
|
||||
return dockerID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remote value
|
||||
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -242,8 +245,11 @@ func (transport *Transport) getDockerID() (string, error) {
|
||||
}
|
||||
|
||||
if info.Swarm.Cluster != nil {
|
||||
return info.Swarm.Cluster.ID, nil
|
||||
transport.dockerID = info.Swarm.Cluster.ID
|
||||
return transport.dockerID, nil
|
||||
}
|
||||
|
||||
return info.ID, nil
|
||||
transport.dockerID = info.ID
|
||||
|
||||
return transport.dockerID, nil
|
||||
}
|
||||
|
||||
@@ -161,7 +161,10 @@ func (server *Server) Start() error {
|
||||
edgeJobsHandler.FileService = server.FileService
|
||||
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
|
||||
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService)
|
||||
edgeStackCoordinator := edgestacks.NewEdgeStackStatusUpdateCoordinator(server.DataStore)
|
||||
go edgeStackCoordinator.Start()
|
||||
|
||||
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService, edgeStackCoordinator)
|
||||
edgeStacksHandler.FileService = server.FileService
|
||||
edgeStacksHandler.GitService = server.GitService
|
||||
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
|
||||
|
||||
@@ -9,7 +9,6 @@ func NewStatus(oldStatus map[portainer.EndpointID]portainer.EdgeStackStatus, rel
|
||||
status := map[portainer.EndpointID]portainer.EdgeStackStatus{}
|
||||
|
||||
for _, environmentID := range relatedEnvironmentIDs {
|
||||
|
||||
newEnvStatus := portainer.EdgeStackStatus{
|
||||
Status: []portainer.EdgeStackDeploymentStatus{},
|
||||
EndpointID: environmentID,
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Service repesents a service to manage environment(endpoint) snapshots.
|
||||
// Service represents a service to manage environment(endpoint) snapshots.
|
||||
// It provides an interface to start background snapshots as well as
|
||||
// specific Docker/Kubernetes environment(endpoint) snapshot methods.
|
||||
type Service struct {
|
||||
@@ -174,30 +174,6 @@ func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint) error {
|
||||
return FillSnapshotData(service.dataStore, endpoint)
|
||||
}
|
||||
|
||||
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
|
||||
snapshot, err := tx.Snapshot().Read(endpoint.ID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{}
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if snapshot.Docker != nil {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker}
|
||||
}
|
||||
|
||||
if snapshot.Kubernetes != nil {
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot.Kubernetes}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) snapshotKubernetesEndpoint(endpoint *portainer.Endpoint) error {
|
||||
kubernetesSnapshot, err := service.kubernetesSnapshotter.CreateSnapshot(endpoint)
|
||||
if err != nil {
|
||||
@@ -285,11 +261,16 @@ func (service *Service) snapshotEndpoints() error {
|
||||
|
||||
snapshotError := service.SnapshotEndpoint(&endpoint)
|
||||
|
||||
service.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := service.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
updateEndpointStatus(tx, &endpoint, snapshotError, service.pendingActionsService)
|
||||
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(endpoint.ID)).
|
||||
Msg("unable to update environment status")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -340,12 +321,31 @@ func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) {
|
||||
return info.ID, nil
|
||||
}
|
||||
|
||||
swarmInfo := info.Swarm
|
||||
if swarmInfo.Cluster == nil {
|
||||
if info.Swarm.Cluster == nil {
|
||||
return "", errors.New("swarm environment is missing cluster info snapshot")
|
||||
}
|
||||
|
||||
clusterInfo := swarmInfo.Cluster
|
||||
|
||||
return clusterInfo.ID, nil
|
||||
return info.Swarm.Cluster.ID, nil
|
||||
}
|
||||
|
||||
func FillSnapshotData(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) error {
|
||||
snapshot, err := tx.Snapshot().Read(endpoint.ID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{}
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
|
||||
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if snapshot.Docker != nil {
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker}
|
||||
}
|
||||
|
||||
if snapshot.Kubernetes != nil {
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{*snapshot.Kubernetes}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
123
api/kubernetes/cli/cronjob.go
Normal file
123
api/kubernetes/cli/cronjob.go
Normal 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)
|
||||
}
|
||||
66
api/kubernetes/cli/cronjob_test.go
Normal file
66
api/kubernetes/cli/cronjob_test.go
Normal 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
227
api/kubernetes/cli/job.go
Normal 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)
|
||||
}
|
||||
64
api/kubernetes/cli/job_test.go
Normal file
64
api/kubernetes/cli/job_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings
|
||||
return "", err
|
||||
}
|
||||
|
||||
maps.Copy(idToken, resource)
|
||||
maps.Copy(resource, idToken)
|
||||
|
||||
username, err := GetUsername(resource, configuration.UserIdentifier)
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -370,6 +370,7 @@ type (
|
||||
Error string
|
||||
// EE only feature
|
||||
RollbackTo *int
|
||||
Version int `json:"Version,omitempty"`
|
||||
}
|
||||
|
||||
// EdgeStackStatusType represents an edge stack status type
|
||||
@@ -1395,6 +1396,13 @@ type (
|
||||
Prune bool
|
||||
}
|
||||
|
||||
ComposeDownOptions struct {
|
||||
// RemoveVolumes will remove the named volumes declared in the compose file
|
||||
// and anonymous volumes attached to the stack's containers
|
||||
// Drives `docker compose down --volumes`
|
||||
RemoveVolumes bool
|
||||
}
|
||||
|
||||
ComposeRunOptions struct {
|
||||
ComposeOptions
|
||||
|
||||
@@ -1628,9 +1636,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.25.0"
|
||||
APIVersion = "2.27.0-rc1"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
APIVersionSupport = "LTS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,7 @@ class KubernetesConfigurationConverter {
|
||||
static secretToConfiguration(secret) {
|
||||
const res = new KubernetesConfiguration();
|
||||
res.Kind = KubernetesConfigurationKinds.SECRET;
|
||||
res.kind = 'Secret';
|
||||
res.Id = secret.Id;
|
||||
res.Name = secret.Name;
|
||||
res.Type = secret.Type;
|
||||
@@ -19,8 +20,15 @@ class KubernetesConfigurationConverter {
|
||||
res.IsRegistrySecret = secret.IsRegistrySecret;
|
||||
res.SecretType = secret.SecretType;
|
||||
if (secret.Annotations) {
|
||||
const serviceAccountAnnotation = secret.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name');
|
||||
res.ServiceAccountName = serviceAccountAnnotation ? serviceAccountAnnotation.value : undefined;
|
||||
const serviceAccountKey = 'kubernetes.io/service-account.name';
|
||||
if (typeof secret.Annotations === 'object') {
|
||||
res.ServiceAccountName = secret.Annotations[serviceAccountKey];
|
||||
} else if (Array.isArray(secret.Annotations)) {
|
||||
const serviceAccountAnnotation = secret.Annotations.find((a) => a.key === 'kubernetes.io/service-account.name');
|
||||
res.ServiceAccountName = serviceAccountAnnotation ? serviceAccountAnnotation.value : undefined;
|
||||
} else {
|
||||
res.ServiceAccountName = undefined;
|
||||
}
|
||||
}
|
||||
res.Labels = secret.Labels;
|
||||
return res;
|
||||
@@ -29,6 +37,7 @@ class KubernetesConfigurationConverter {
|
||||
static configMapToConfiguration(configMap) {
|
||||
const res = new KubernetesConfiguration();
|
||||
res.Kind = KubernetesConfigurationKinds.CONFIGMAP;
|
||||
res.kind = 'ConfigMap';
|
||||
res.Id = configMap.Id;
|
||||
res.Name = configMap.Name;
|
||||
res.Namespace = configMap.Namespace;
|
||||
|
||||
@@ -9,6 +9,7 @@ const _KubernetesConfigurationFormValues = Object.freeze({
|
||||
Name: '',
|
||||
ConfigurationOwner: '',
|
||||
Kind: KubernetesConfigurationKinds.CONFIGMAP,
|
||||
kind: 'ConfigMap',
|
||||
Data: [],
|
||||
DataYaml: '',
|
||||
IsSimple: true,
|
||||
|
||||
@@ -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))), [])
|
||||
|
||||
@@ -22,29 +22,6 @@
|
||||
</kubernetes-resource-reservation>
|
||||
</form>
|
||||
<!-- !resource-reservation -->
|
||||
|
||||
<!-- leader-status -->
|
||||
<div ng-if="ctrl.systemEndpoints.length > 0">
|
||||
<div class="col-sm-12 form-section-title"> Leader status </div>
|
||||
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr class="text-muted">
|
||||
<td style="border-top: none; width: 25%">Component</td>
|
||||
<td style="border-top: none; width: 25%">Leader node</td>
|
||||
</tr>
|
||||
<tr ng-repeat="ep in ctrl.systemEndpoints">
|
||||
<td style="width: 25%">
|
||||
{{ ep.Name }}
|
||||
</td>
|
||||
<td style="width: 25%">
|
||||
{{ ep.HolderIdentity }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- !leader-status -->
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<page-header ng-if="ctrl.state.viewReady" title="'Create from file'" breadcrumbs="['Deploy Kubernetes resources']" reload="true"></page-header>
|
||||
<page-header ng-if="ctrl.state.viewReady" title="'Create from code'" breadcrumbs="['Deploy Kubernetes resources']" reload="true"></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
/**
|
||||
* @deprecated use withGlobalError
|
||||
* `onError` and other callbacks are not supported on react-query v5
|
||||
* @deprecated for `useQuery` ONLY. Use `withGlobalError`.
|
||||
*
|
||||
* `onError` and other callbacks are not supported on `useQuery` in react-query v5
|
||||
*
|
||||
* Using `withError` is fine for mutations (`useMutation`)
|
||||
*
|
||||
* see https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose
|
||||
*/
|
||||
export function withError(fallbackMessage?: string, title = 'Failure') {
|
||||
return {
|
||||
|
||||
52
app/react-tools/useDebugPropChanges.ts
Normal file
52
app/react-tools/useDebugPropChanges.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/* eslint-disable no-console */
|
||||
import { intersection } from 'lodash';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
function logPropDifferences(
|
||||
newProps: Record<string, unknown>,
|
||||
lastProps: Record<string, unknown>,
|
||||
verbose: boolean
|
||||
) {
|
||||
const allKeys = intersection(Object.keys(newProps), Object.keys(lastProps));
|
||||
|
||||
const changedKeys: string[] = [];
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
const newValue = newProps[key];
|
||||
const lastValue = lastProps[key];
|
||||
if (newValue !== lastValue) {
|
||||
changedKeys.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
if (changedKeys.length) {
|
||||
if (verbose) {
|
||||
changedKeys.forEach((key) => {
|
||||
const newValue = newProps[key];
|
||||
const lastValue = lastProps[key];
|
||||
console.log('Key [', key, '] changed');
|
||||
console.log('From: ', lastValue);
|
||||
console.log('To: ', newValue);
|
||||
console.log('------');
|
||||
});
|
||||
} else {
|
||||
console.log('Changed keys: ', changedKeys.join());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useDebugPropChanges(
|
||||
newProps: Record<string, unknown>,
|
||||
verbose: boolean = true
|
||||
) {
|
||||
const lastProps = useRef<Record<string, unknown>>();
|
||||
// Should only run when the component re-mounts
|
||||
useEffect(() => {
|
||||
console.log('Mounted');
|
||||
}, []);
|
||||
if (lastProps.current) {
|
||||
logPropDifferences(newProps, lastProps.current, verbose);
|
||||
}
|
||||
lastProps.current = newProps;
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
@@ -3,7 +3,7 @@ import { StreamLanguage, LanguageSupport } from '@codemirror/language';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { createTheme } from '@uiw/codemirror-themes';
|
||||
import { tags as highlightTags } from '@lezer/highlight';
|
||||
|
||||
@@ -11,6 +11,8 @@ import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
|
||||
import styles from './CodeEditor.module.css';
|
||||
import { TextTip } from './Tip/TextTip';
|
||||
import { StackVersionSelector } from './StackVersionSelector';
|
||||
@@ -89,17 +91,17 @@ export function CodeEditor({
|
||||
return extensions;
|
||||
}, [type]);
|
||||
|
||||
function handleVersionChange(version: number) {
|
||||
if (versions && versions.length > 1) {
|
||||
if (version < versions[0]) {
|
||||
setIsRollback(true);
|
||||
} else {
|
||||
setIsRollback(false);
|
||||
const handleVersionChange = useCallback(
|
||||
(version: number) => {
|
||||
if (versions && versions.length > 1) {
|
||||
setIsRollback(version < versions[0]);
|
||||
}
|
||||
}
|
||||
onVersionChange?.(version);
|
||||
},
|
||||
[onVersionChange, versions]
|
||||
);
|
||||
|
||||
onVersionChange?.(version);
|
||||
}
|
||||
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -136,8 +138,8 @@ export function CodeEditor({
|
||||
<CodeMirror
|
||||
className={styles.root}
|
||||
theme={theme}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
value={debouncedValue}
|
||||
onChange={debouncedOnChange}
|
||||
readOnly={readonly || isRollback}
|
||||
id={id}
|
||||
extensions={extensions}
|
||||
|
||||
@@ -40,17 +40,10 @@ export function useDocsUrl(doc?: string): string {
|
||||
}
|
||||
|
||||
let url = 'https://docs.portainer.io/';
|
||||
if (versionQuery.data) {
|
||||
let { ServerVersion } = versionQuery.data;
|
||||
if (ServerVersion[0] === 'v') {
|
||||
ServerVersion = ServerVersion.substring(1);
|
||||
}
|
||||
|
||||
const parts = ServerVersion.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const version = parts.slice(0, 2).join('.');
|
||||
url += `v/${version}`;
|
||||
}
|
||||
// Add LTS or STS version if we have it
|
||||
if (versionQuery.data?.VersionSupport) {
|
||||
url += versionQuery.data.VersionSupport.toLowerCase();
|
||||
}
|
||||
|
||||
if (doc) {
|
||||
|
||||
@@ -104,6 +104,7 @@ export function TagSelector({
|
||||
onCreateOption={handleCreateOption}
|
||||
aria-label="Tags"
|
||||
data-cy="environment-tags-selector"
|
||||
id="environment-tags-selector"
|
||||
/>
|
||||
</FormControl>
|
||||
</>
|
||||
|
||||
@@ -36,6 +36,7 @@ export function UsersSelector({
|
||||
onChange(selectedUsers.map((user) => user.Id))
|
||||
}
|
||||
data-cy={dataCy}
|
||||
id={dataCy}
|
||||
inputId={inputId}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
|
||||
@@ -18,6 +18,7 @@ export function Select<T extends number | string>({
|
||||
options,
|
||||
className,
|
||||
'data-cy': dataCy,
|
||||
id,
|
||||
...props
|
||||
}: Props<T> & SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
|
||||
@@ -111,6 +111,7 @@ export function SingleSelect<TValue = string>({
|
||||
onChange={(option) => onChange(option ? option.value : null)}
|
||||
isOptionDisabled={(option) => !!option.disabled}
|
||||
data-cy={dataCy}
|
||||
id={dataCy}
|
||||
inputId={inputId}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
@@ -177,6 +178,7 @@ export function MultiSelect<TValue = string>({
|
||||
closeMenuOnSelect={false}
|
||||
onChange={(newValue) => onChange(newValue.map((option) => option.value))}
|
||||
data-cy={dataCy}
|
||||
id={dataCy}
|
||||
inputId={inputId}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
|
||||
@@ -65,6 +65,7 @@ export function Select<
|
||||
}: Props<Option, IsMulti, Group> &
|
||||
AutomationTestingProps & {
|
||||
isItemVisible?: (item: Option, search: string) => boolean;
|
||||
id: string;
|
||||
}) {
|
||||
const Component = isCreatable ? ReactSelectCreatable : ReactSelect;
|
||||
const { options } = props;
|
||||
|
||||
@@ -152,6 +152,7 @@ export function GpuFieldset({
|
||||
options={options}
|
||||
components={{ MultiValueRemove }}
|
||||
data-cy="docker-containers-gpu-select"
|
||||
id="docker-containers-gpu-select"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -173,6 +174,7 @@ export function GpuFieldset({
|
||||
components={{ Option }}
|
||||
onChange={onChangeSelectedCaps}
|
||||
data-cy="docker-containers-gpu-capabilities-select"
|
||||
id="docker-containers-gpu-capabilities-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,7 @@ export function VolumeSelector({
|
||||
onChange={(vol) => onChange(vol?.Name)}
|
||||
inputId={inputId}
|
||||
data-cy="docker-containers-volume-selector"
|
||||
id="docker-containers-volume-selector"
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ export function CreatableSelector({
|
||||
isDisabled={isLoading}
|
||||
closeMenuOnSelect={false}
|
||||
data-cy="edge-devices-assignment-selector"
|
||||
id="edge-devices-assignment-selector"
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export function GroupSelector() {
|
||||
placeholder="Select a group"
|
||||
isClearable
|
||||
data-cy="edge-devices-assignment-selector"
|
||||
id="edge-devices-assignment-selector"
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -29,13 +29,16 @@ export function CreateForm() {
|
||||
const [webhookId] = useState(() => createWebhookId());
|
||||
|
||||
const [templateParams, setTemplateParams] = useTemplateParams();
|
||||
const templateQuery = useTemplate(templateParams.type, templateParams.id);
|
||||
const templateQuery = useTemplate(
|
||||
templateParams.templateType,
|
||||
templateParams.templateId
|
||||
);
|
||||
|
||||
const validation = useValidation(templateQuery);
|
||||
const mutation = useCreate({
|
||||
webhookId,
|
||||
template: templateQuery.customTemplate || templateQuery.appTemplate,
|
||||
templateType: templateParams.type,
|
||||
templateType: templateParams.templateType,
|
||||
});
|
||||
|
||||
const initialValues = useInitialValues(templateQuery, templateParams);
|
||||
@@ -53,6 +56,7 @@ export function CreateForm() {
|
||||
initialValues={initialValues}
|
||||
onSubmit={mutation.onSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
>
|
||||
<InnerForm
|
||||
webhookId={webhookId}
|
||||
@@ -118,8 +122,8 @@ function useInitialValues(
|
||||
customTemplate: CustomTemplate | undefined;
|
||||
},
|
||||
templateParams: {
|
||||
id: number | undefined;
|
||||
type: 'app' | 'custom' | undefined;
|
||||
templateId: number | undefined;
|
||||
templateType: 'app' | 'custom' | undefined;
|
||||
}
|
||||
) {
|
||||
const template = templateQuery.customTemplate || templateQuery.appTemplate;
|
||||
@@ -139,7 +143,7 @@ function useInitialValues(
|
||||
staggerConfig:
|
||||
templateQuery.customTemplate?.EdgeSettings?.StaggerConfig ??
|
||||
getDefaultStaggerConfig(),
|
||||
method: templateParams.id ? 'template' : 'editor',
|
||||
method: templateParams.templateId ? 'template' : 'editor',
|
||||
git: toGitFormModel(
|
||||
templateQuery.customTemplate?.GitConfig,
|
||||
parseAutoUpdateResponse()
|
||||
@@ -149,19 +153,19 @@ function useInitialValues(
|
||||
getDefaultRelativePathModel(),
|
||||
enableWebhook: false,
|
||||
fileContent: '',
|
||||
templateValues: getTemplateValues(templateParams.type, template),
|
||||
templateValues: getTemplateValues(templateParams.templateType, template),
|
||||
useManifestNamespaces: false,
|
||||
}),
|
||||
[
|
||||
templateQuery.customTemplate,
|
||||
templateParams.id,
|
||||
templateParams.type,
|
||||
templateParams.templateId,
|
||||
templateParams.templateType,
|
||||
template,
|
||||
]
|
||||
);
|
||||
|
||||
if (
|
||||
templateParams.id &&
|
||||
templateParams.templateId &&
|
||||
!templateQuery.customTemplate &&
|
||||
!templateQuery.appTemplate
|
||||
) {
|
||||
|
||||
@@ -17,7 +17,11 @@ import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { relativePathValidation } from '@/react/portainer/gitops/RelativePathFieldset/validation';
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
|
||||
import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types';
|
||||
import {
|
||||
DeployMethod,
|
||||
GitFormModel,
|
||||
RelativePathModel,
|
||||
} from '@/react/portainer/gitops/types';
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
|
||||
import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
@@ -133,7 +137,10 @@ export function useValidation({
|
||||
);
|
||||
},
|
||||
}) as SchemaOf<GitFormModel>,
|
||||
relativePath: relativePathValidation(),
|
||||
relativePath: mixed().when('method', {
|
||||
is: 'repository',
|
||||
then: () => relativePathValidation(),
|
||||
}) as SchemaOf<RelativePathModel>,
|
||||
useManifestNamespaces: boolean().default(false),
|
||||
})
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormikErrors, useFormikContext } from 'formik';
|
||||
import { SetStateAction } from 'react';
|
||||
import { SetStateAction, useCallback } from 'react';
|
||||
|
||||
import { GitForm } from '@/react/portainer/gitops/GitForm';
|
||||
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||
@@ -28,8 +28,8 @@ const buildMethods = [editor, upload, git, edgeStackTemplate] as const;
|
||||
interface Props {
|
||||
webhookId: string;
|
||||
onChangeTemplate: (change: {
|
||||
type: 'app' | 'custom' | undefined;
|
||||
id: number | undefined;
|
||||
templateType: 'app' | 'custom' | undefined;
|
||||
templateId: number | undefined;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,23 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
const { errors, values, setValues } = useFormikContext<DockerFormValues>();
|
||||
const { method } = values;
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValues: Partial<DockerFormValues>) => {
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
...newValues,
|
||||
}));
|
||||
},
|
||||
[setValues]
|
||||
);
|
||||
|
||||
const saveFileContent = useCallback(
|
||||
(value: string) => {
|
||||
handleChange({ fileContent: value });
|
||||
},
|
||||
[handleChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormSection title="Build Method">
|
||||
@@ -59,8 +76,8 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
values.templateValues
|
||||
);
|
||||
onChangeTemplate({
|
||||
id: templateValues.templateId,
|
||||
type: templateValues.type,
|
||||
templateId: templateValues.templateId,
|
||||
templateType: templateValues.type,
|
||||
});
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
@@ -91,7 +108,7 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
{method === editor.value && (
|
||||
<DockerContentField
|
||||
value={values.fileContent}
|
||||
onChange={(value) => handleChange({ fileContent: value })}
|
||||
onChange={saveFileContent}
|
||||
error={errors?.fileContent}
|
||||
/>
|
||||
)}
|
||||
@@ -128,6 +145,7 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
<FormSection title="Advanced configurations">
|
||||
<RelativePathFieldset
|
||||
values={values.relativePath}
|
||||
errors={errors.relativePath}
|
||||
gitModel={values.git}
|
||||
onChange={(relativePath) =>
|
||||
setValues((values) => ({
|
||||
@@ -145,13 +163,6 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function handleChange(newValues: Partial<DockerFormValues>) {
|
||||
setValues((values) => ({
|
||||
...values,
|
||||
...newValues,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
type TemplateContentFieldProps = {
|
||||
|
||||
@@ -29,11 +29,11 @@ export function InnerForm({
|
||||
webhookId: string;
|
||||
isLoading: boolean;
|
||||
onChangeTemplate: ({
|
||||
type,
|
||||
id,
|
||||
templateType,
|
||||
templateId,
|
||||
}: {
|
||||
type: 'app' | 'custom' | undefined;
|
||||
id: number | undefined;
|
||||
templateType: 'app' | 'custom' | undefined;
|
||||
templateId: number | undefined;
|
||||
}) => void;
|
||||
}) {
|
||||
const { values, setFieldValue, errors, setValues, setFieldError, isValid } =
|
||||
@@ -128,6 +128,7 @@ export function InnerForm({
|
||||
<StaggerFieldset
|
||||
isEdit={false}
|
||||
values={values.staggerConfig}
|
||||
errors={errors.staggerConfig}
|
||||
onChange={(newStaggerValues) =>
|
||||
setFieldValue('staggerConfig', newStaggerValues)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export function TemplateSelector({
|
||||
onChange(getTemplate({ type, id: templateId }), type);
|
||||
}}
|
||||
data-cy="edge-stacks-create-template-selector"
|
||||
id="edge-stacks-create-template-selector"
|
||||
/>
|
||||
{isLoadingValues && (
|
||||
<InlineLoader>Loading template values...</InlineLoader>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TemplateViewModel } from '@/react/portainer/templates/app-templates/vie
|
||||
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||
import { mutationOptions, withError } from '@/react-tools/react-query';
|
||||
|
||||
import {
|
||||
BasePayload,
|
||||
@@ -49,12 +50,18 @@ export function useCreate({
|
||||
),
|
||||
});
|
||||
|
||||
mutation.mutate(getPayload(method, values), {
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Edge stack created');
|
||||
router.stateService.go('^');
|
||||
},
|
||||
});
|
||||
mutation.mutate(
|
||||
getPayload(method, values),
|
||||
mutationOptions(
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Edge stack created');
|
||||
router.stateService.go('^');
|
||||
},
|
||||
},
|
||||
withError('unable to create edge stack')
|
||||
)
|
||||
);
|
||||
|
||||
function getPayload(
|
||||
method: 'string' | 'file' | 'git',
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useParamsState } from '@/react/hooks/useParamState';
|
||||
|
||||
export function useTemplateParams() {
|
||||
const [{ id, type }, setTemplateParams] = useParamsState(
|
||||
['templateId', 'templateType'],
|
||||
const [{ templateId, templateType }, setTemplateParams] = useParamsState(
|
||||
(params) => ({
|
||||
id: parseTemplateId(params.templateId),
|
||||
type: parseTemplateType(params.templateType),
|
||||
templateId: parseTemplateId(params.templateId),
|
||||
templateType: parseTemplateType(params.templateType),
|
||||
})
|
||||
);
|
||||
|
||||
return [{ id, type }, setTemplateParams] as const;
|
||||
return [{ templateId, templateType }, setTemplateParams] as const;
|
||||
}
|
||||
|
||||
function parseTemplateId(param?: string) {
|
||||
|
||||
@@ -97,6 +97,7 @@ function InnerSelector({
|
||||
placeholder="Select one or multiple group(s)"
|
||||
closeMenuOnSelect={false}
|
||||
data-cy="edge-stacks-groups-selector"
|
||||
id="edge-stacks-groups-selector"
|
||||
inputId={inputId}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -102,6 +102,7 @@ export function PrivateRegistryFieldset({
|
||||
onChange={(value) => onSelect(value?.Id)}
|
||||
className="w-full"
|
||||
data-cy="private-registry-selector"
|
||||
id="private-registry-selector"
|
||||
/>
|
||||
{method !== 'repository' && (
|
||||
<Button
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -22,32 +22,15 @@ export function useParamState<T>(
|
||||
|
||||
/** Use this when you need to use/update multiple params at once. */
|
||||
export function useParamsState<T extends Record<string, unknown>>(
|
||||
params: string[],
|
||||
parseParams: (params: Record<string, string | undefined>) => T
|
||||
) {
|
||||
const { params: stateParams } = useCurrentStateAndParams();
|
||||
const router = useRouter();
|
||||
|
||||
const state = parseParams(
|
||||
params.reduce(
|
||||
(acc, param) => {
|
||||
acc[param] = stateParams[param];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | undefined>
|
||||
)
|
||||
);
|
||||
const state = parseParams(stateParams);
|
||||
|
||||
function setState(newState: Partial<T>) {
|
||||
const newStateParams = Object.entries(newState).reduce(
|
||||
(acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
);
|
||||
|
||||
router.stateService.go('.', newStateParams, { reload: false });
|
||||
router.stateService.go('.', newState, { reload: false });
|
||||
}
|
||||
|
||||
return [state, setState] as const;
|
||||
|
||||
@@ -122,6 +122,7 @@ export function AppIngressPathForm({
|
||||
onChangeIngressPath(newIngressPath);
|
||||
}}
|
||||
data-cy="k8sAppCreate-ingressPathHostSelect"
|
||||
id="k8sAppCreate-ingressPathHostSelect"
|
||||
/>
|
||||
<InputGroup.ButtonWrapper>
|
||||
<Button
|
||||
|
||||
@@ -7,7 +7,7 @@ export function HelmInsightsBox() {
|
||||
content={
|
||||
<span>
|
||||
From 2.20 and on, the Helm menu sidebar option has moved to the{' '}
|
||||
<strong>Create from file screen</strong> - accessed via the button
|
||||
<strong>Create from code screen</strong> - accessed via the button
|
||||
above.
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -7,64 +7,74 @@ import { Icon } from '@@/Icon';
|
||||
import { Application } from './types';
|
||||
|
||||
export function PublishedPorts({ item }: { item: Application }) {
|
||||
const urls = getPublishedUrls(item);
|
||||
const urlsWithTypes = getPublishedUrls(item);
|
||||
|
||||
if (urls.length === 0) {
|
||||
if (urlsWithTypes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="published-url-container">
|
||||
<div>
|
||||
<div className="text-muted"> Published URL(s) </div>
|
||||
</div>
|
||||
<div>
|
||||
{urls.map((url) => (
|
||||
<div key={url}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="publish-url-link vertical-center"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon icon={ExternalLinkIcon} />
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
<div className="published-url-container pl-10 flex">
|
||||
<div className="text-muted mr-12">Published URL(s)</div>
|
||||
<div className="flex flex-col">
|
||||
{urlsWithTypes.map(({ url, type }) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="publish-url-link vertical-center mb-1"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon icon={ExternalLinkIcon} />
|
||||
{type && (
|
||||
<span className="text-muted w-24 inline-block">{type}</span>
|
||||
)}
|
||||
<span>{url}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getClusterIPUrls(services?: Application['Services']) {
|
||||
return (
|
||||
services?.flatMap(
|
||||
(service) =>
|
||||
(service.spec?.type === 'ClusterIP' &&
|
||||
service.spec?.ports?.map((port) => ({
|
||||
url: `${getSchemeFromPort(port.port)}://${service?.spec
|
||||
?.clusterIP}:${port.port}`,
|
||||
type: 'ClusterIP',
|
||||
}))) ||
|
||||
[]
|
||||
) || []
|
||||
);
|
||||
}
|
||||
|
||||
function getNodePortUrls(services?: Application['Services']) {
|
||||
return (
|
||||
services?.flatMap(
|
||||
(service) =>
|
||||
(service.spec?.type === 'NodePort' &&
|
||||
service.spec?.ports?.map((port) => ({
|
||||
url: `${getSchemeFromPort(port.port)}://${
|
||||
window.location.hostname
|
||||
}:${port.nodePort}`,
|
||||
type: 'NodePort',
|
||||
}))) ||
|
||||
[]
|
||||
) || []
|
||||
);
|
||||
}
|
||||
|
||||
export function getPublishedUrls(item: Application) {
|
||||
// Map all ingress rules in published ports to their respective URLs
|
||||
const ingressUrls =
|
||||
item.PublishedPorts?.flatMap((pp) => pp.IngressRules)
|
||||
.filter(({ Host, IP }) => Host || IP)
|
||||
.map(({ Host, IP, Path, TLS }) => {
|
||||
const scheme =
|
||||
TLS &&
|
||||
TLS.filter((tls) => tls.hosts && tls.hosts.includes(Host)).length > 0
|
||||
? 'https'
|
||||
: 'http';
|
||||
return `${scheme}://${Host || IP}${Path}`;
|
||||
}) || [];
|
||||
// Get URLs from clusterIP and nodePort services
|
||||
const clusterIPs = getClusterIPUrls(item.Services);
|
||||
const nodePortUrls = getNodePortUrls(item.Services);
|
||||
|
||||
// Map all load balancer service ports to ip address
|
||||
const loadBalancerURLs =
|
||||
(item.LoadBalancerIPAddress &&
|
||||
item.PublishedPorts?.map(
|
||||
(pp) =>
|
||||
`${getSchemeFromPort(pp.Port)}://${item.LoadBalancerIPAddress}:${
|
||||
pp.Port
|
||||
}`
|
||||
)) ||
|
||||
[];
|
||||
// combine all urls
|
||||
const publishedUrls = [...clusterIPs, ...nodePortUrls];
|
||||
|
||||
// combine ingress urls
|
||||
const publishedUrls = [...ingressUrls, ...loadBalancerURLs];
|
||||
|
||||
// Return the first URL - priority given to ingress urls, then services (load balancers)
|
||||
return publishedUrls.length > 0 ? publishedUrls : [];
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export function ConfigurationItem({
|
||||
onChange={onSelectConfigMap}
|
||||
size="sm"
|
||||
data-cy={`k8sAppCreate-add${configurationType}Select_${index}`}
|
||||
id={`k8sAppCreate-add${configurationType}Select_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{formikError?.selectedConfiguration && (
|
||||
|
||||
@@ -144,6 +144,7 @@ export function PersistedFolderItem({
|
||||
applicationValues.Containers.length > 1
|
||||
}
|
||||
data-cy={`k8sAppCreate-persistentFolderSizeUnitSelect_${index}`}
|
||||
id={`k8sAppCreate-persistentFolderSizeUnitSelect_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
{formikError?.size && <FormError>{formikError?.size}</FormError>}
|
||||
@@ -175,6 +176,7 @@ export function PersistedFolderItem({
|
||||
storageClasses.length <= 1
|
||||
}
|
||||
data-cy={`k8sAppCreate-storageSelect_${index}`}
|
||||
id={`k8sAppCreate-storageSelect_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
</>
|
||||
@@ -207,6 +209,7 @@ export function PersistedFolderItem({
|
||||
availableVolumes.length < 1
|
||||
}
|
||||
data-cy={`k8sAppCreate-pvcSelect_${index}`}
|
||||
id={`k8sAppCreate-pvcSelect_${index}`}
|
||||
/>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
@@ -49,6 +49,7 @@ export function PlacementItem({
|
||||
className={clsx({ striked: !!item.needsDeletion })}
|
||||
isDisabled={!!item.needsDeletion}
|
||||
data-cy={`k8sAppCreate-placementLabel_${index}`}
|
||||
id={`k8sAppCreate-placementLabel_${index}`}
|
||||
/>
|
||||
{placementError?.label && (
|
||||
<FormError>{placementError.label}</FormError>
|
||||
@@ -65,6 +66,7 @@ export function PlacementItem({
|
||||
className={clsx({ striked: !!item.needsDeletion })}
|
||||
isDisabled={!!item.needsDeletion}
|
||||
data-cy={`k8sAppCreate-placementName_${index}`}
|
||||
id={`k8sAppCreate-placementName_${index}`}
|
||||
/>
|
||||
{placementError?.value && (
|
||||
<FormError>{placementError.value}</FormError>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CronJob, CronJobList } from 'kubernetes-types/batch/v1';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import axios from '@/portainer/services/axios';
|
||||
|
||||
import { parseKubernetesAxiosError } from '../axiosError';
|
||||
@@ -24,7 +24,7 @@ export function useCronJobs(
|
||||
queryKeys.cronJobsForCluster(environmentId),
|
||||
() => getCronJobsForCluster(environmentId, namespaces),
|
||||
{
|
||||
...withError('Unable to retrieve CronJobs'),
|
||||
...withGlobalError('Unable to retrieve CronJobs'),
|
||||
enabled: !!namespaces?.length,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Job, JobList } from 'kubernetes-types/batch/v1';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
import axios from '@/portainer/services/axios';
|
||||
|
||||
import { parseKubernetesAxiosError } from '../axiosError';
|
||||
@@ -21,7 +21,7 @@ export function useJobs(environmentId: EnvironmentId, namespaces?: string[]) {
|
||||
queryKeys.jobsForCluster(environmentId),
|
||||
() => getJobsForCluster(environmentId, namespaces),
|
||||
{
|
||||
...withError('Unable to retrieve Jobs'),
|
||||
...withGlobalError('Unable to retrieve Jobs'),
|
||||
enabled: !!namespaces?.length,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -69,11 +69,10 @@ export function getTotalPods(
|
||||
): number {
|
||||
switch (application.kind) {
|
||||
case 'Deployment':
|
||||
return application.status?.replicas ?? 0;
|
||||
case 'StatefulSet':
|
||||
return application.spec?.replicas ?? 0;
|
||||
case 'DaemonSet':
|
||||
return application.status?.desiredNumberScheduled ?? 0;
|
||||
case 'StatefulSet':
|
||||
return application.status?.replicas ?? 0;
|
||||
default:
|
||||
throw new Error('Unknown application type');
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export function StorageAccessModeSelector({
|
||||
inputId={inputId}
|
||||
placeholder="Not configured"
|
||||
data-cy={`kubeSetup-storageAccessSelect${storageClassName}`}
|
||||
id={`kubeSetup-storageAccessSelect${storageClassName}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export function NamespacesSelector({
|
||||
onChange(selectedTeams.map((namespace) => namespace.name))
|
||||
}
|
||||
data-cy={dataCy}
|
||||
id={dataCy}
|
||||
inputId={inputId}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function CreateFromManifestButton({
|
||||
}}
|
||||
data-cy={dataCy}
|
||||
>
|
||||
Create from file
|
||||
Create from code
|
||||
</AddButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ export function IngressForm({
|
||||
}
|
||||
noOptionsMessage={() => 'No namespaces available'}
|
||||
data-cy="k8sAppCreate-namespaceSelect"
|
||||
id="k8sAppCreate-namespaceSelect"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -266,6 +267,7 @@ export function IngressForm({
|
||||
}
|
||||
noOptionsMessage={() => 'No ingress classes available'}
|
||||
data-cy="k8sAppCreate-ingressClassSelect"
|
||||
id="k8sAppCreate-ingressClassSelect"
|
||||
/>
|
||||
{errors.className && (
|
||||
<FormError className="error-inline mt-1">
|
||||
@@ -464,6 +466,7 @@ export function IngressForm({
|
||||
noOptionsMessage={() => 'No TLS secrets available'}
|
||||
size="sm"
|
||||
data-cy={`k8sAppCreate-tlsSelect_${hostIndex}`}
|
||||
id={`k8sAppCreate-tlsSelect_${hostIndex}`}
|
||||
/>
|
||||
{!host.NoHost && (
|
||||
<div className="input-group-btn">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { CalendarCheck2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
DefaultDatatableSettings,
|
||||
TableSettings as KubeTableSettings,
|
||||
} from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||
|
||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
import {
|
||||
type FilteredColumnsTableSettings,
|
||||
BasicTableSettings,
|
||||
} from '@@/datatables/types';
|
||||
import { TableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { columns } from '../JobsDatatable/columns';
|
||||
import { Job } from '../JobsDatatable/types';
|
||||
|
||||
interface TableSettings
|
||||
extends KubeTableSettings,
|
||||
FilteredColumnsTableSettings {}
|
||||
|
||||
interface CronJobsExecutionsProps {
|
||||
item: Job[];
|
||||
tableState: TableSettings;
|
||||
}
|
||||
|
||||
export function CronJobsExecutionsInnerDatatable({
|
||||
item,
|
||||
tableState,
|
||||
}: CronJobsExecutionsProps) {
|
||||
return (
|
||||
<Datatable
|
||||
dataset={item}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.Id}
|
||||
disableSelect
|
||||
title="Executions"
|
||||
titleIcon={CalendarCheck2}
|
||||
data-cy="k8s-cronJobs-executions-datatable"
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
<DefaultDatatableSettings settings={tableState} />
|
||||
</TableSettingsMenu>
|
||||
)}
|
||||
settingsManager={tableState as unknown as TableState<BasicTableSettings>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const command = columnHelper.accessor((row) => row.Command, {
|
||||
header: 'Command',
|
||||
id: 'command',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { buildExpandColumn } from '@@/datatables/expand-column';
|
||||
|
||||
import { CronJob } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const expand = columnHelper.display({
|
||||
...buildExpandColumn<CronJob>(),
|
||||
id: 'expand',
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { CronJob } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<CronJob>();
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expand } from './expand';
|
||||
import { name } from './name';
|
||||
import { namespace } from './namespace';
|
||||
import { schedule } from './schedule';
|
||||
import { suspend } from './suspend';
|
||||
import { timezone } from './timezone';
|
||||
import { command } from './command';
|
||||
|
||||
export const columns = [
|
||||
expand,
|
||||
name,
|
||||
namespace,
|
||||
command,
|
||||
schedule,
|
||||
suspend,
|
||||
timezone,
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SystemBadge } from '@@/Badge/SystemBadge';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const name = columnHelper.accessor(
|
||||
(row) => {
|
||||
let result = row.Name;
|
||||
if (row.IsSystem) {
|
||||
result += ' system';
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
id: 'name',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2">
|
||||
{row.original.Name}
|
||||
{row.original.IsSystem && <SystemBadge />}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Row } from '@tanstack/react-table';
|
||||
|
||||
import { filterHOC } from '@@/datatables/Filter';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { CronJob } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const namespace = columnHelper.accessor((row) => row.Namespace, {
|
||||
header: 'Namespace',
|
||||
id: 'namespace',
|
||||
cell: ({ getValue, row }) => (
|
||||
<Link
|
||||
to="kubernetes.resourcePools.resourcePool"
|
||||
params={{
|
||||
id: getValue(),
|
||||
}}
|
||||
title={getValue()}
|
||||
data-cy={`cronJob-namespace-link-${row.original.Name}`}
|
||||
>
|
||||
{getValue()}
|
||||
</Link>
|
||||
),
|
||||
meta: {
|
||||
filter: filterHOC('Filter by namespace'),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
filterFn: (row: Row<CronJob>, _columnId: string, filterValue: string[]) =>
|
||||
filterValue.length === 0 ||
|
||||
filterValue.includes(row.original.Namespace ?? ''),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const schedule = columnHelper.accessor((row) => row.Schedule, {
|
||||
header: 'Schedule',
|
||||
id: 'schedule',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const suspend = columnHelper.accessor((row) => row.Suspend, {
|
||||
header: 'Suspend',
|
||||
id: 'suspend',
|
||||
cell: ({ getValue }) => {
|
||||
const suspended = getValue();
|
||||
return suspended ? 'Yes' : 'No';
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const timezone = columnHelper.accessor((row) => row.Timezone, {
|
||||
header: 'Timezone',
|
||||
id: 'timezone',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user