mirror of https://github.com/portainer/portainer
refactor(edge/updates): sync changes from EE [EE-4288] (#7726)
parent
4fee359247
commit
82e9e2a895
|
@ -32,6 +32,7 @@ import (
|
|||
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
"github.com/portainer/portainer/api/internal/ssl"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
|
@ -587,6 +588,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||
|
||||
digitalSignatureService := initDigitalSignatureService()
|
||||
|
||||
edgeStacksService := edgestacks.NewService(dataStore)
|
||||
|
||||
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
|
@ -738,6 +741,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
|||
HTTPEnabled: sslDBSettings.HTTPEnabled,
|
||||
AssetsPath: *flags.Assets,
|
||||
DataStore: dataStore,
|
||||
EdgeStacksService: edgeStacksService,
|
||||
SwarmStackManager: swarmStackManager,
|
||||
ComposeStackManager: composeStackManager,
|
||||
KubernetesDeployer: kubernetesDeployer,
|
||||
|
|
|
@ -1,186 +0,0 @@
|
|||
package edgeupdateschedule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/edgetypes"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// BucketName represents the name of the bucket where this service stores data.
|
||||
BucketName = "edge_update_schedule"
|
||||
)
|
||||
|
||||
// Service represents a service for managing Edge Update Schedule data.
|
||||
type Service struct {
|
||||
connection portainer.Connection
|
||||
|
||||
mu sync.Mutex
|
||||
idxActiveSchedules map[portainer.EndpointID]*edgetypes.EndpointUpdateScheduleRelation
|
||||
}
|
||||
|
||||
func (service *Service) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
|
||||
// NewService creates a new instance of a service.
|
||||
func NewService(connection portainer.Connection) (*Service, error) {
|
||||
err := connection.SetServiceName(BucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
connection: connection,
|
||||
}
|
||||
|
||||
service.idxActiveSchedules = map[portainer.EndpointID]*edgetypes.EndpointUpdateScheduleRelation{}
|
||||
|
||||
schedules, err := service.List()
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "Unable to list schedules")
|
||||
}
|
||||
|
||||
for _, schedule := range schedules {
|
||||
service.setRelation(&schedule)
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (service *Service) ActiveSchedule(environmentID portainer.EndpointID) *edgetypes.EndpointUpdateScheduleRelation {
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
return service.idxActiveSchedules[environmentID]
|
||||
}
|
||||
|
||||
func (service *Service) ActiveSchedules(environmentsIDs []portainer.EndpointID) []edgetypes.EndpointUpdateScheduleRelation {
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
schedules := []edgetypes.EndpointUpdateScheduleRelation{}
|
||||
|
||||
for _, environmentID := range environmentsIDs {
|
||||
if s, ok := service.idxActiveSchedules[environmentID]; ok {
|
||||
schedules = append(schedules, *s)
|
||||
}
|
||||
}
|
||||
|
||||
return schedules
|
||||
}
|
||||
|
||||
// List return an array containing all the items in the bucket.
|
||||
func (service *Service) List() ([]edgetypes.UpdateSchedule, error) {
|
||||
var list = make([]edgetypes.UpdateSchedule, 0)
|
||||
|
||||
err := service.connection.GetAll(
|
||||
BucketName,
|
||||
&edgetypes.UpdateSchedule{},
|
||||
func(obj interface{}) (interface{}, error) {
|
||||
item, ok := obj.(*edgetypes.UpdateSchedule)
|
||||
if !ok {
|
||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to EdgeUpdateSchedule object")
|
||||
return nil, fmt.Errorf("Failed to convert to EdgeUpdateSchedule object: %s", obj)
|
||||
}
|
||||
list = append(list, *item)
|
||||
return &edgetypes.UpdateSchedule{}, nil
|
||||
})
|
||||
|
||||
return list, err
|
||||
}
|
||||
|
||||
// Item returns a item by ID.
|
||||
func (service *Service) Item(ID edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error) {
|
||||
var item edgetypes.UpdateSchedule
|
||||
identifier := service.connection.ConvertToKey(int(ID))
|
||||
|
||||
err := service.connection.GetObject(BucketName, identifier, &item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
// Create assign an ID to a new object and saves it.
|
||||
func (service *Service) Create(item *edgetypes.UpdateSchedule) error {
|
||||
err := service.connection.CreateObject(
|
||||
BucketName,
|
||||
func(id uint64) (int, interface{}) {
|
||||
item.ID = edgetypes.UpdateScheduleID(id)
|
||||
return int(item.ID), item
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return service.setRelation(item)
|
||||
}
|
||||
|
||||
// Update updates an item.
|
||||
func (service *Service) Update(id edgetypes.UpdateScheduleID, item *edgetypes.UpdateSchedule) error {
|
||||
identifier := service.connection.ConvertToKey(int(id))
|
||||
err := service.connection.UpdateObject(BucketName, identifier, item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.cleanRelation(id)
|
||||
|
||||
return service.setRelation(item)
|
||||
}
|
||||
|
||||
// Delete deletes an item.
|
||||
func (service *Service) Delete(id edgetypes.UpdateScheduleID) error {
|
||||
|
||||
service.cleanRelation(id)
|
||||
|
||||
identifier := service.connection.ConvertToKey(int(id))
|
||||
return service.connection.DeleteObject(BucketName, identifier)
|
||||
}
|
||||
|
||||
func (service *Service) cleanRelation(id edgetypes.UpdateScheduleID) {
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
for _, schedule := range service.idxActiveSchedules {
|
||||
if schedule != nil && schedule.ScheduleID == id {
|
||||
delete(service.idxActiveSchedules, schedule.EnvironmentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *Service) setRelation(schedule *edgetypes.UpdateSchedule) error {
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
for environmentID, environmentStatus := range schedule.Status {
|
||||
if environmentStatus.Status != edgetypes.UpdateScheduleStatusPending {
|
||||
continue
|
||||
}
|
||||
|
||||
// this should never happen
|
||||
if service.idxActiveSchedules[environmentID] != nil && service.idxActiveSchedules[environmentID].ScheduleID != schedule.ID {
|
||||
return errors.New("Multiple schedules are pending for the same environment")
|
||||
}
|
||||
|
||||
service.idxActiveSchedules[environmentID] = &edgetypes.EndpointUpdateScheduleRelation{
|
||||
EnvironmentID: environmentID,
|
||||
ScheduleID: schedule.ID,
|
||||
TargetVersion: environmentStatus.TargetVersion,
|
||||
Status: environmentStatus.Status,
|
||||
Error: environmentStatus.Error,
|
||||
Type: schedule.Type,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -8,7 +8,6 @@ import (
|
|||
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/dataservices/errors"
|
||||
"github.com/portainer/portainer/api/edgetypes"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
@ -29,7 +28,6 @@ type (
|
|||
EdgeGroup() EdgeGroupService
|
||||
EdgeJob() EdgeJobService
|
||||
EdgeStack() EdgeStackService
|
||||
EdgeUpdateSchedule() EdgeUpdateScheduleService
|
||||
Endpoint() EndpointService
|
||||
EndpointGroup() EndpointGroupService
|
||||
EndpointRelation() EndpointRelationService
|
||||
|
@ -85,17 +83,6 @@ type (
|
|||
BucketName() string
|
||||
}
|
||||
|
||||
EdgeUpdateScheduleService interface {
|
||||
ActiveSchedule(environmentID portainer.EndpointID) *edgetypes.EndpointUpdateScheduleRelation
|
||||
ActiveSchedules(environmentIDs []portainer.EndpointID) []edgetypes.EndpointUpdateScheduleRelation
|
||||
List() ([]edgetypes.UpdateSchedule, error)
|
||||
Item(ID edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error)
|
||||
Create(edgeUpdateSchedule *edgetypes.UpdateSchedule) error
|
||||
Update(ID edgetypes.UpdateScheduleID, edgeUpdateSchedule *edgetypes.UpdateSchedule) error
|
||||
Delete(ID edgetypes.UpdateScheduleID) error
|
||||
BucketName() string
|
||||
}
|
||||
|
||||
// EdgeStackService represents a service to manage Edge stacks
|
||||
EdgeStackService interface {
|
||||
EdgeStacks() ([]portainer.EdgeStack, error)
|
||||
|
|
|
@ -14,7 +14,6 @@ import (
|
|||
"github.com/portainer/portainer/api/dataservices/edgegroup"
|
||||
"github.com/portainer/portainer/api/dataservices/edgejob"
|
||||
"github.com/portainer/portainer/api/dataservices/edgestack"
|
||||
"github.com/portainer/portainer/api/dataservices/edgeupdateschedule"
|
||||
"github.com/portainer/portainer/api/dataservices/endpoint"
|
||||
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
||||
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
||||
|
@ -50,7 +49,6 @@ type Store struct {
|
|||
DockerHubService *dockerhub.Service
|
||||
EdgeGroupService *edgegroup.Service
|
||||
EdgeJobService *edgejob.Service
|
||||
EdgeUpdateScheduleService *edgeupdateschedule.Service
|
||||
EdgeStackService *edgestack.Service
|
||||
EndpointGroupService *endpointgroup.Service
|
||||
EndpointService *endpoint.Service
|
||||
|
@ -95,12 +93,6 @@ func (store *Store) initServices() error {
|
|||
}
|
||||
store.DockerHubService = dockerhubService
|
||||
|
||||
edgeUpdateScheduleService, err := edgeupdateschedule.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.EdgeUpdateScheduleService = edgeUpdateScheduleService
|
||||
|
||||
edgeStackService, err := edgestack.NewService(store.connection)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -263,11 +255,6 @@ func (store *Store) EdgeJob() dataservices.EdgeJobService {
|
|||
return store.EdgeJobService
|
||||
}
|
||||
|
||||
// EdgeUpdateSchedule gives access to the EdgeUpdateSchedule data management layer
|
||||
func (store *Store) EdgeUpdateSchedule() dataservices.EdgeUpdateScheduleService {
|
||||
return store.EdgeUpdateScheduleService
|
||||
}
|
||||
|
||||
// EdgeStack gives access to the EdgeStack data management layer
|
||||
func (store *Store) EdgeStack() dataservices.EdgeStackService {
|
||||
return store.EdgeStackService
|
||||
|
|
|
@ -1,102 +0,0 @@
|
|||
package edgetypes
|
||||
|
||||
import portainer "github.com/portainer/portainer/api"
|
||||
|
||||
const (
|
||||
// PortainerAgentUpdateScheduleIDHeader represents the name of the header containing the update schedule id
|
||||
PortainerAgentUpdateScheduleIDHeader = "X-Portainer-Update-Schedule-ID"
|
||||
// PortainerAgentUpdateStatusHeader is the name of the header that will have the update status
|
||||
PortainerAgentUpdateStatusHeader = "X-Portainer-Update-Status"
|
||||
// PortainerAgentUpdateErrorHeader is the name of the header that will have the update error
|
||||
PortainerAgentUpdateErrorHeader = "X-Portainer-Update-Error"
|
||||
)
|
||||
|
||||
type (
|
||||
|
||||
// UpdateScheduleID represents an Edge schedule identifier
|
||||
UpdateScheduleID int
|
||||
|
||||
// UpdateSchedule represents a schedule for update/rollback of edge devices
|
||||
UpdateSchedule struct {
|
||||
// EdgeUpdateSchedule Identifier
|
||||
ID UpdateScheduleID `json:"id" example:"1"`
|
||||
// Name of the schedule
|
||||
Name string `json:"name" example:"Update Schedule"`
|
||||
// Type of the schedule
|
||||
Time int64 `json:"time" example:"1564897200"`
|
||||
// EdgeGroups to be updated
|
||||
GroupIDs []portainer.EdgeGroupID `json:"groupIds" example:"1"`
|
||||
// Type of the update (1 - update, 2 - rollback)
|
||||
Type UpdateScheduleType `json:"type" example:"1" enums:"1,2"`
|
||||
// Status of the schedule, grouped by environment id
|
||||
Status map[portainer.EndpointID]UpdateScheduleStatus `json:"status"`
|
||||
// Created timestamp
|
||||
Created int64 `json:"created" example:"1564897200"`
|
||||
// Created by user id
|
||||
CreatedBy portainer.UserID `json:"createdBy" example:"1"`
|
||||
}
|
||||
|
||||
// UpdateScheduleType represents type of an Edge update schedule
|
||||
UpdateScheduleType int
|
||||
|
||||
// UpdateScheduleStatus represents status of an Edge update schedule
|
||||
UpdateScheduleStatus struct {
|
||||
// Status of the schedule (0 - pending, 1 - failed, 2 - success)
|
||||
Status UpdateScheduleStatusType `json:"status" example:"1" enums:"1,2,3"`
|
||||
// Error message if status is failed
|
||||
Error string `json:"error" example:"error message"`
|
||||
// Target version of the edge agent
|
||||
TargetVersion string `json:"targetVersion" example:"1"`
|
||||
// Current version of the edge agent
|
||||
CurrentVersion string `json:"currentVersion" example:"1"`
|
||||
}
|
||||
|
||||
// UpdateScheduleStatusType represents status type of an Edge update schedule
|
||||
UpdateScheduleStatusType int
|
||||
|
||||
VersionUpdateRequest struct {
|
||||
// Target version
|
||||
Version string
|
||||
// Scheduled time
|
||||
ScheduledTime int64
|
||||
// If need to update
|
||||
Active bool
|
||||
// Update schedule ID
|
||||
ScheduleID UpdateScheduleID
|
||||
}
|
||||
|
||||
// VersionUpdateStatus represents the status of an agent version update
|
||||
VersionUpdateStatus struct {
|
||||
Status UpdateScheduleStatusType
|
||||
ScheduleID UpdateScheduleID
|
||||
Error string
|
||||
}
|
||||
|
||||
// EndpointUpdateScheduleRelation represents the relation between an environment(endpoint) and an update schedule
|
||||
EndpointUpdateScheduleRelation struct {
|
||||
EnvironmentID portainer.EndpointID `json:"environmentId"`
|
||||
ScheduleID UpdateScheduleID `json:"scheduleId"`
|
||||
TargetVersion string `json:"targetVersion"`
|
||||
Status UpdateScheduleStatusType `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Type UpdateScheduleType `json:"type"`
|
||||
ScheduledTime int64 `json:"scheduledTime"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
_ UpdateScheduleType = iota
|
||||
// UpdateScheduleUpdate represents an edge device scheduled for an update
|
||||
UpdateScheduleUpdate
|
||||
// UpdateScheduleRollback represents an edge device scheduled for a rollback
|
||||
UpdateScheduleRollback
|
||||
)
|
||||
|
||||
const (
|
||||
// UpdateScheduleStatusPending represents a pending edge update schedule
|
||||
UpdateScheduleStatusPending UpdateScheduleStatusType = iota
|
||||
// UpdateScheduleStatusError represents a failed edge update schedule
|
||||
UpdateScheduleStatusError
|
||||
// UpdateScheduleStatusSuccess represents a successful edge update schedule
|
||||
UpdateScheduleStatusSuccess
|
||||
)
|
|
@ -3,20 +3,16 @@ package edgestacks
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
type InvalidPayloadError struct {
|
||||
|
@ -47,8 +43,14 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
|||
if err != nil {
|
||||
return httperror.BadRequest("Invalid query parameter: method", err)
|
||||
}
|
||||
dryrun, _ := request.RetrieveBooleanQueryParameter(r, "dryrun", true)
|
||||
|
||||
edgeStack, err := handler.createSwarmStack(method, r)
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user details from authentication token", err)
|
||||
}
|
||||
|
||||
edgeStack, err := handler.createSwarmStack(method, dryrun, tokenData.ID, r)
|
||||
if err != nil {
|
||||
var payloadError *InvalidPayloadError
|
||||
switch {
|
||||
|
@ -62,14 +64,15 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
|||
return response.JSON(w, edgeStack)
|
||||
}
|
||||
|
||||
func (handler *Handler) createSwarmStack(method string, r *http.Request) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createSwarmStack(method string, dryrun bool, userID portainer.UserID, r *http.Request) (*portainer.EdgeStack, error) {
|
||||
|
||||
switch method {
|
||||
case "string":
|
||||
return handler.createSwarmStackFromFileContent(r)
|
||||
return handler.createSwarmStackFromFileContent(r, dryrun)
|
||||
case "repository":
|
||||
return handler.createSwarmStackFromGitRepository(r)
|
||||
return handler.createSwarmStackFromGitRepository(r, dryrun, userID)
|
||||
case "file":
|
||||
return handler.createSwarmStackFromFileUpload(r)
|
||||
return handler.createSwarmStackFromFileUpload(r, dryrun)
|
||||
}
|
||||
return nil, errors.New("Invalid value for query parameter: method. Value must be one of: string, repository or file")
|
||||
}
|
||||
|
@ -82,10 +85,13 @@ type swarmStackFromFileContentPayload struct {
|
|||
// List of identifiers of EdgeGroups
|
||||
EdgeGroups []portainer.EdgeGroupID `example:"1"`
|
||||
// Deployment type to deploy this stack
|
||||
// Valid values are: 0 - 'compose', 1 - 'kubernetes'
|
||||
// Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad'
|
||||
// for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints)
|
||||
// kubernetes deploytype is enabled only for kubernetes environments(endpoints)
|
||||
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"`
|
||||
// kubernetes deploy type is enabled only for kubernetes environments(endpoints)
|
||||
// nomad deploy type is enabled only for nomad environments(endpoints)
|
||||
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"`
|
||||
// List of Registries to use for this stack
|
||||
Registries []portainer.RegistryID
|
||||
}
|
||||
|
||||
func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error {
|
||||
|
@ -101,85 +107,69 @@ func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error
|
|||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createSwarmStackFromFileContent(r *http.Request, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
var payload swarmStackFromFileContentPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = handler.validateUniqueName(payload.Name)
|
||||
stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.Wrap(err, "failed to create Edge stack object")
|
||||
}
|
||||
|
||||
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
||||
stack := &portainer.EdgeStack{
|
||||
ID: portainer.EdgeStackID(stackID),
|
||||
Name: payload.Name,
|
||||
DeploymentType: payload.DeploymentType,
|
||||
CreationDate: time.Now().Unix(),
|
||||
EdgeGroups: payload.EdgeGroups,
|
||||
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
|
||||
Version: 1,
|
||||
if dryrun {
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to find environment relations in database: %w", err)
|
||||
}
|
||||
return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||
return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, []byte(payload.StackFileContent))
|
||||
})
|
||||
|
||||
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to persist environment relation in database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
if stack.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
stack.EntryPoint = filesystem.ComposeFileDefaultName
|
||||
func (handler *Handler) storeFileContent(stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) {
|
||||
if deploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
composePath = filesystem.ComposeFileDefaultName
|
||||
|
||||
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, composePath, fileContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", "", "", err
|
||||
}
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
|
||||
manifestPath, err = handler.convertAndStoreKubeManifestIfNeeded(stackFolder, projectPath, composePath, relatedEndpointIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err)
|
||||
return "", "", "", fmt.Errorf("Failed creating and storing kube manifest: %w", err)
|
||||
}
|
||||
|
||||
} else {
|
||||
hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to check for existence of docker endpoint: %w", err)
|
||||
}
|
||||
return composePath, manifestPath, projectPath, nil
|
||||
|
||||
if hasDockerEndpoint {
|
||||
return nil, fmt.Errorf("edge stack with docker endpoint cannot be deployed with kubernetes config")
|
||||
}
|
||||
|
||||
stack.ManifestPath = filesystem.ManifestFileDefaultName
|
||||
|
||||
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stack.ProjectPath = projectPath
|
||||
}
|
||||
|
||||
err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds)
|
||||
hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Unable to update endpoint relations: %w", err)
|
||||
return "", "", "", fmt.Errorf("unable to check for existence of docker environment: %w", err)
|
||||
}
|
||||
|
||||
err = handler.DataStore.EdgeStack().Create(stack.ID, stack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if hasDockerEndpoint {
|
||||
return "", "", "", fmt.Errorf("edge stack with docker environment cannot be deployed with kubernetes or nomad config")
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
|
||||
manifestPath = filesystem.ManifestFileDefaultName
|
||||
|
||||
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, fileContent)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
return "", manifestPath, projectPath, nil
|
||||
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("invalid deployment type: %d", deploymentType)
|
||||
}
|
||||
|
||||
type swarmStackFromGitRepositoryPayload struct {
|
||||
|
@ -200,10 +190,13 @@ type swarmStackFromGitRepositoryPayload struct {
|
|||
// List of identifiers of EdgeGroups
|
||||
EdgeGroups []portainer.EdgeGroupID `example:"1"`
|
||||
// Deployment type to deploy this stack
|
||||
// Valid values are: 0 - 'compose', 1 - 'kubernetes'
|
||||
// Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad'
|
||||
// for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints)
|
||||
// kubernetes deploytype is enabled only for kubernetes environments(endpoints)
|
||||
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"`
|
||||
// kubernetes deploy type is enabled only for kubernetes environments(endpoints)
|
||||
// nomad deploy type is enabled only for nomad environments(endpoints)
|
||||
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"`
|
||||
// List of Registries to use for this stack
|
||||
Registries []portainer.RegistryID
|
||||
}
|
||||
|
||||
func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error {
|
||||
|
@ -217,7 +210,12 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
|
|||
return &InvalidPayloadError{msg: "Invalid repository credentials. Password must be specified when authentication is enabled"}
|
||||
}
|
||||
if govalidator.IsNull(payload.FilePathInRepository) {
|
||||
payload.FilePathInRepository = filesystem.ComposeFileDefaultName
|
||||
switch payload.DeploymentType {
|
||||
case portainer.EdgeStackDeploymentCompose:
|
||||
payload.FilePathInRepository = filesystem.ComposeFileDefaultName
|
||||
case portainer.EdgeStackDeploymentKubernetes:
|
||||
payload.FilePathInRepository = filesystem.ManifestFileDefaultName
|
||||
}
|
||||
}
|
||||
if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 {
|
||||
return &InvalidPayloadError{msg: "Edge Groups are mandatory for an Edge stack"}
|
||||
|
@ -225,83 +223,50 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
|
|||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) {
|
||||
var payload swarmStackFromGitRepositoryPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = handler.validateUniqueName(payload.Name)
|
||||
stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.Wrap(err, "failed to create edge stack object")
|
||||
}
|
||||
|
||||
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
||||
stack := &portainer.EdgeStack{
|
||||
ID: portainer.EdgeStackID(stackID),
|
||||
Name: payload.Name,
|
||||
CreationDate: time.Now().Unix(),
|
||||
EdgeGroups: payload.EdgeGroups,
|
||||
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
|
||||
DeploymentType: payload.DeploymentType,
|
||||
Version: 1,
|
||||
if dryrun {
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
repositoryUsername := payload.RepositoryUsername
|
||||
repositoryPassword := payload.RepositoryPassword
|
||||
if !payload.RepositoryAuthentication {
|
||||
repositoryUsername = ""
|
||||
repositoryPassword = ""
|
||||
repoConfig := gittypes.RepoConfig{
|
||||
URL: payload.RepositoryURL,
|
||||
ReferenceName: payload.RepositoryReferenceName,
|
||||
}
|
||||
|
||||
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed fetching relations config: %w", err)
|
||||
}
|
||||
|
||||
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve related endpoints: %w", err)
|
||||
}
|
||||
|
||||
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if stack.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
stack.EntryPoint = payload.FilePathInRepository
|
||||
|
||||
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err)
|
||||
if payload.RepositoryAuthentication {
|
||||
repoConfig.Authentication = &gittypes.GitAuthentication{
|
||||
Username: payload.RepositoryUsername,
|
||||
Password: payload.RepositoryPassword,
|
||||
}
|
||||
} else {
|
||||
stack.ManifestPath = payload.FilePathInRepository
|
||||
}
|
||||
|
||||
err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Unable to update environment relations: %w", err)
|
||||
}
|
||||
|
||||
err = handler.DataStore.EdgeStack().Create(stack.ID, stack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||
return handler.storeManifestFromGitRepository(stackFolder, relatedEndpointIds, payload.DeploymentType, userID, repoConfig)
|
||||
})
|
||||
}
|
||||
|
||||
type swarmStackFromFileUploadPayload struct {
|
||||
Name string
|
||||
StackFileContent []byte
|
||||
EdgeGroups []portainer.EdgeGroupID
|
||||
DeploymentType portainer.EdgeStackDeploymentType
|
||||
// Deployment type to deploy this stack
|
||||
// Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad'
|
||||
// for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints)
|
||||
// kubernetes deploytype is enabled only for kubernetes environments(endpoints)
|
||||
// nomad deploytype is enabled only for nomad environments(endpoints)
|
||||
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"`
|
||||
Registries []portainer.RegistryID
|
||||
}
|
||||
|
||||
func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error {
|
||||
|
@ -330,109 +295,67 @@ func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error
|
|||
}
|
||||
payload.DeploymentType = portainer.EdgeStackDeploymentType(deploymentType)
|
||||
|
||||
var registries []portainer.RegistryID
|
||||
request.RetrieveMultiPartFormJSONValue(r, "Registries", ®istries, false)
|
||||
if err != nil {
|
||||
return errors.New("Invalid registry type")
|
||||
}
|
||||
payload.Registries = registries
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portainer.EdgeStack, error) {
|
||||
func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
payload := &swarmStackFromFileUploadPayload{}
|
||||
err := payload.Validate(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = handler.validateUniqueName(payload.Name)
|
||||
stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.Wrap(err, "failed to create edge stack object")
|
||||
}
|
||||
|
||||
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
||||
stack := &portainer.EdgeStack{
|
||||
ID: portainer.EdgeStackID(stackID),
|
||||
Name: payload.Name,
|
||||
DeploymentType: payload.DeploymentType,
|
||||
CreationDate: time.Now().Unix(),
|
||||
EdgeGroups: payload.EdgeGroups,
|
||||
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
|
||||
Version: 1,
|
||||
if dryrun {
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed fetching relations config: %w", err)
|
||||
}
|
||||
|
||||
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve related endpoints: %w", err)
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
if stack.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
stack.EntryPoint = filesystem.ComposeFileDefaultName
|
||||
|
||||
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err)
|
||||
}
|
||||
|
||||
} else {
|
||||
stack.ManifestPath = filesystem.ManifestFileDefaultName
|
||||
|
||||
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stack.ProjectPath = projectPath
|
||||
}
|
||||
|
||||
err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Unable to update environment relations: %w", err)
|
||||
}
|
||||
|
||||
err = handler.DataStore.EdgeStack().Create(stack.ID, stack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||
return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, payload.StackFileContent)
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *Handler) validateUniqueName(name string) error {
|
||||
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
|
||||
func (handler *Handler) storeManifestFromGitRepository(stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) {
|
||||
projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder)
|
||||
repositoryUsername := ""
|
||||
repositoryPassword := ""
|
||||
if repositoryConfig.Authentication != nil {
|
||||
if repositoryConfig.Authentication.Password != "" {
|
||||
repositoryUsername = repositoryConfig.Authentication.Username
|
||||
repositoryPassword = repositoryConfig.Authentication.Password
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
for _, stack := range edgeStacks {
|
||||
if strings.EqualFold(stack.Name, name) {
|
||||
return errors.New("Edge stack name must be unique")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if deploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
composePath := repositoryConfig.ConfigFilePath
|
||||
|
||||
// updateEndpointRelations adds a relation between the Edge Stack to the related environments(endpoints)
|
||||
func updateEndpointRelations(endpointRelationService dataservices.EndpointRelationService, edgeStackID portainer.EdgeStackID, relatedEndpointIds []portainer.EndpointID) error {
|
||||
for _, endpointID := range relatedEndpointIds {
|
||||
relation, err := endpointRelationService.EndpointRelation(endpointID)
|
||||
manifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, projectPath, composePath, relatedEndpointIds)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to find environment relation in database: %w", err)
|
||||
return "", "", "", fmt.Errorf("Failed creating and storing kube manifest: %w", err)
|
||||
}
|
||||
|
||||
relation.EdgeStacks[edgeStackID] = true
|
||||
|
||||
err = endpointRelationService.UpdateEndpointRelation(endpointID, relation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to persist environment relation in database: %w", err)
|
||||
}
|
||||
return composePath, manifestPath, projectPath, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
return "", repositoryConfig.ConfigFilePath, projectPath, nil
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("unknown deployment type: %d", deploymentType)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import (
|
|||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
)
|
||||
|
||||
// @id EdgeStackDelete
|
||||
|
@ -35,33 +34,9 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request)
|
|||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.DataStore.EdgeStack().DeleteEdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
err = handler.edgeStacksService.DeleteEdgeStack(edgeStack.ID, edgeStack.EdgeGroups)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to remove the edge stack from the database", err)
|
||||
}
|
||||
|
||||
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to find environment relations in database", err)
|
||||
}
|
||||
|
||||
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
|
||||
}
|
||||
|
||||
for _, endpointID := range relatedEndpointIds {
|
||||
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to find environment relation in database", err)
|
||||
}
|
||||
|
||||
delete(relation.EdgeStacks, edgeStack.ID)
|
||||
|
||||
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist environment relation in database", err)
|
||||
}
|
||||
return httperror.InternalServerError("Unable to delete edge stack", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
|
|
|
@ -14,19 +14,22 @@ import (
|
|||
type updateStatusPayload struct {
|
||||
Error string
|
||||
Status *portainer.EdgeStackStatusType
|
||||
EndpointID *portainer.EndpointID
|
||||
EndpointID portainer.EndpointID
|
||||
}
|
||||
|
||||
func (payload *updateStatusPayload) Validate(r *http.Request) error {
|
||||
if payload.Status == nil {
|
||||
return errors.New("Invalid status")
|
||||
}
|
||||
if payload.EndpointID == nil {
|
||||
|
||||
if payload.EndpointID == 0 {
|
||||
return errors.New("Invalid EnvironmentID")
|
||||
}
|
||||
|
||||
if *payload.Status == portainer.StatusError && govalidator.IsNull(payload.Error) {
|
||||
return errors.New("Error message is mandatory when status is error")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -62,7 +65,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
|||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(*payload.EndpointID))
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(payload.EndpointID)
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
|
@ -74,10 +77,10 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
|||
return httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
stack.Status[*payload.EndpointID] = portainer.EdgeStackStatus{
|
||||
stack.Status[payload.EndpointID] = portainer.EdgeStackStatus{
|
||||
Type: *payload.Status,
|
||||
Error: payload.Error,
|
||||
EndpointID: *payload.EndpointID,
|
||||
EndpointID: payload.EndpointID,
|
||||
}
|
||||
|
||||
err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
@ -69,9 +70,12 @@ func setupHandler(t *testing.T) (*Handler, string, func()) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
edgeStacksService := edgestacks.NewService(store)
|
||||
|
||||
handler := NewHandler(
|
||||
security.NewRequestBouncer(store, jwtService, apiKeyService),
|
||||
store,
|
||||
edgeStacksService,
|
||||
)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
@ -779,7 +783,7 @@ func TestUpdateStatusAndInspect(t *testing.T) {
|
|||
payload := updateStatusPayload{
|
||||
Error: "test-error",
|
||||
Status: &newStatus,
|
||||
EndpointID: &endpoint.ID,
|
||||
EndpointID: endpoint.ID,
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
|
@ -829,7 +833,7 @@ func TestUpdateStatusAndInspect(t *testing.T) {
|
|||
t.Fatalf(fmt.Sprintf("expected EdgeStackStatusError %s, found %s", payload.Error, data.Status[endpoint.ID].Error))
|
||||
}
|
||||
|
||||
if data.Status[endpoint.ID].EndpointID != *payload.EndpointID {
|
||||
if data.Status[endpoint.ID].EndpointID != payload.EndpointID {
|
||||
t.Fatalf(fmt.Sprintf("expected EndpointID %d, found %d", payload.EndpointID, data.Status[endpoint.ID].EndpointID))
|
||||
}
|
||||
}
|
||||
|
@ -854,7 +858,7 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) {
|
|||
updateStatusPayload{
|
||||
Error: "test-error",
|
||||
Status: nil,
|
||||
EndpointID: &endpoint.ID,
|
||||
EndpointID: endpoint.ID,
|
||||
},
|
||||
"Invalid status",
|
||||
400,
|
||||
|
@ -864,17 +868,17 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) {
|
|||
updateStatusPayload{
|
||||
Error: "",
|
||||
Status: &statusError,
|
||||
EndpointID: &endpoint.ID,
|
||||
EndpointID: endpoint.ID,
|
||||
},
|
||||
"Error message is mandatory when status is error",
|
||||
400,
|
||||
},
|
||||
{
|
||||
"Update with nil EndpointID",
|
||||
"Update with missing EndpointID",
|
||||
updateStatusPayload{
|
||||
Error: "",
|
||||
Status: &statusOk,
|
||||
EndpointID: nil,
|
||||
EndpointID: 0,
|
||||
},
|
||||
"Invalid EnvironmentID",
|
||||
400,
|
||||
|
|
|
@ -65,18 +65,20 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
|||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
||||
relationConfig, err := edge.FetchEndpointRelationsConfig(handler.DataStore)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve environments relations config from database", err)
|
||||
}
|
||||
|
||||
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
|
||||
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
|
||||
}
|
||||
|
||||
endpointsToAdd := map[portainer.EndpointID]bool{}
|
||||
|
||||
if payload.EdgeGroups != nil {
|
||||
newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
|
||||
newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
|
||||
}
|
||||
|
@ -105,7 +107,6 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
|||
}
|
||||
}
|
||||
|
||||
endpointsToAdd := map[portainer.EndpointID]bool{}
|
||||
for endpointID := range newRelatedSet {
|
||||
if !oldRelatedSet[endpointID] {
|
||||
endpointsToAdd[endpointID] = true
|
||||
|
@ -143,22 +144,26 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
|||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
|
||||
if payload.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
if stack.EntryPoint == "" {
|
||||
stack.EntryPoint = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
|
||||
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
_, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
|
||||
}
|
||||
|
||||
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
|
||||
manifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, stack.ProjectPath, stack.EntryPoint, relatedEndpointIds)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to convert and persist updated Kubernetes manifest file on disk", err)
|
||||
}
|
||||
|
||||
} else {
|
||||
stack.ManifestPath = manifestPath
|
||||
}
|
||||
|
||||
if payload.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
if stack.ManifestPath == "" {
|
||||
stack.ManifestPath = filesystem.ManifestFileDefaultName
|
||||
}
|
||||
|
@ -174,11 +179,12 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
|
||||
return httperror.InternalServerError("Unable to persist updated Kubernetes manifest file on disk", err)
|
||||
}
|
||||
}
|
||||
|
||||
if payload.Version != nil && *payload.Version != stack.Version {
|
||||
versionUpdated := payload.Version != nil && *payload.Version != stack.Version
|
||||
if versionUpdated {
|
||||
stack.Version = *payload.Version
|
||||
stack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{}
|
||||
}
|
||||
|
|
|
@ -30,32 +30,3 @@ func hasEndpointPredicate(endpointService dataservices.EndpointService, endpoint
|
|||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type endpointRelationsConfig struct {
|
||||
endpoints []portainer.Endpoint
|
||||
endpointGroups []portainer.EndpointGroup
|
||||
edgeGroups []portainer.EdgeGroup
|
||||
}
|
||||
|
||||
func fetchEndpointRelationsConfig(dataStore dataservices.DataStore) (*endpointRelationsConfig, error) {
|
||||
endpoints, err := dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve environments from database: %w", err)
|
||||
}
|
||||
|
||||
endpointGroups, err := dataStore.EndpointGroup().EndpointGroups()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve environment groups from database: %w", err)
|
||||
}
|
||||
|
||||
edgeGroups, err := dataStore.EdgeGroup().EdgeGroups()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve edge groups from database: %w", err)
|
||||
}
|
||||
|
||||
return &endpointRelationsConfig{
|
||||
endpoints: endpoints,
|
||||
endpointGroups: endpointGroups,
|
||||
edgeGroups: edgeGroups,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ package edgestacks
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
|
@ -12,6 +11,7 @@ import (
|
|||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler used to handle environment(endpoint) group operations.
|
||||
|
@ -21,16 +21,19 @@ type Handler struct {
|
|||
DataStore dataservices.DataStore
|
||||
FileService portainer.FileService
|
||||
GitService portainer.GitService
|
||||
edgeStacksService *edgestackservice.Service
|
||||
KubernetesDeployer portainer.KubernetesDeployer
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) *Handler {
|
||||
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
DataStore: dataStore,
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
DataStore: dataStore,
|
||||
edgeStacksService: edgeStacksService,
|
||||
}
|
||||
|
||||
h.Handle("/edge_stacks",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
|
||||
h.Handle("/edge_stacks",
|
||||
|
@ -54,33 +57,31 @@ func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataSto
|
|||
return h
|
||||
}
|
||||
|
||||
func (handler *Handler) convertAndStoreKubeManifestIfNeeded(edgeStack *portainer.EdgeStack, relatedEndpointIds []portainer.EndpointID) error {
|
||||
func (handler *Handler) convertAndStoreKubeManifestIfNeeded(stackFolder string, projectPath, composePath string, relatedEndpointIds []portainer.EndpointID) (manifestPath string, err error) {
|
||||
hasKubeEndpoint, err := hasKubeEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to check if edge stack has kube environments: %w", err)
|
||||
return "", fmt.Errorf("unable to check if edge stack has kube environments: %w", err)
|
||||
}
|
||||
|
||||
if !hasKubeEndpoint {
|
||||
return nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
composeConfig, err := handler.FileService.GetFileContent(edgeStack.ProjectPath, edgeStack.EntryPoint)
|
||||
composeConfig, err := handler.FileService.GetFileContent(projectPath, composePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to retrieve Compose file from disk: %w", err)
|
||||
return "", fmt.Errorf("unable to retrieve Compose file from disk: %w", err)
|
||||
}
|
||||
|
||||
kompose, err := handler.KubernetesDeployer.ConvertCompose(composeConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed converting compose file to kubernetes manifest: %w", err)
|
||||
return "", fmt.Errorf("failed converting compose file to kubernetes manifest: %w", err)
|
||||
}
|
||||
|
||||
komposeFileName := filesystem.ManifestFileDefaultName
|
||||
_, err = handler.FileService.StoreEdgeStackFileFromBytes(strconv.Itoa(int(edgeStack.ID)), komposeFileName, kompose)
|
||||
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, komposeFileName, kompose)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store kube manifest file: %w", err)
|
||||
return "", fmt.Errorf("failed to store kube manifest file: %w", err)
|
||||
}
|
||||
|
||||
edgeStack.ManifestPath = komposeFileName
|
||||
|
||||
return nil
|
||||
return komposeFileName, nil
|
||||
}
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
package edgeupdateschedules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
)
|
||||
|
||||
// @id AgentVersions
|
||||
// @summary Fetches the supported versions of the agent to update/rollback
|
||||
// @description
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags edge_update_schedules
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @success 200 {array} string
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /edge_update_schedules/agent_versions [get]
|
||||
func (h *Handler) agentVersions(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
return response.JSON(w, []string{
|
||||
"2.13.0",
|
||||
"2.13.1",
|
||||
"2.14.0",
|
||||
"2.14.1",
|
||||
"2.14.2",
|
||||
"2.15", // for develop only
|
||||
"develop", // for develop only
|
||||
})
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
package edgeupdateschedules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type activeSchedulePayload struct {
|
||||
EnvironmentIDs []portainer.EndpointID
|
||||
}
|
||||
|
||||
func (payload *activeSchedulePayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id EdgeUpdateScheduleActiveSchedulesList
|
||||
// @summary Fetches the list of Active Edge Update Schedules
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_update_schedules
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @param body body activeSchedulePayload true "Active schedule query"
|
||||
// @produce json
|
||||
// @success 200 {array} edgetypes.EndpointUpdateScheduleRelation
|
||||
// @failure 500
|
||||
// @router /edge_update_schedules/active [get]
|
||||
func (handler *Handler) activeSchedules(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload activeSchedulePayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
list := handler.dataStore.EdgeUpdateSchedule().ActiveSchedules(payload.EnvironmentIDs)
|
||||
|
||||
return response.JSON(w, list)
|
||||
}
|
|
@ -1,134 +0,0 @@
|
|||
package edgeupdateschedules
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/edgetypes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
type createPayload struct {
|
||||
Name string
|
||||
GroupIDs []portainer.EdgeGroupID
|
||||
Type edgetypes.UpdateScheduleType
|
||||
Environments map[portainer.EndpointID]string
|
||||
Time int64
|
||||
}
|
||||
|
||||
func (payload *createPayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.Name) {
|
||||
return errors.New("Invalid tag name")
|
||||
}
|
||||
|
||||
if len(payload.GroupIDs) == 0 {
|
||||
return errors.New("Required to choose at least one group")
|
||||
}
|
||||
|
||||
if len(payload.Environments) == 0 {
|
||||
return errors.New("No Environment is scheduled for update")
|
||||
}
|
||||
|
||||
if payload.Type != edgetypes.UpdateScheduleRollback && payload.Type != edgetypes.UpdateScheduleUpdate {
|
||||
return errors.New("Invalid schedule type")
|
||||
}
|
||||
|
||||
if payload.Time < time.Now().Unix() {
|
||||
return errors.New("Invalid time")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id EdgeUpdateScheduleCreate
|
||||
// @summary Creates a new Edge Update Schedule
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_update_schedules
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @param body body createPayload true "Schedule details"
|
||||
// @produce json
|
||||
// @success 200 {object} edgetypes.UpdateSchedule
|
||||
// @failure 500
|
||||
// @router /edge_update_schedules [post]
|
||||
func (handler *Handler) create(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
|
||||
var payload createPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
err = handler.validateUniqueName(payload.Name, 0)
|
||||
if err != nil {
|
||||
return httperror.NewError(http.StatusConflict, "Edge update schedule name already in use", err)
|
||||
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user information from token", err)
|
||||
}
|
||||
|
||||
item := &edgetypes.UpdateSchedule{
|
||||
Name: payload.Name,
|
||||
Time: payload.Time,
|
||||
GroupIDs: payload.GroupIDs,
|
||||
Status: map[portainer.EndpointID]edgetypes.UpdateScheduleStatus{},
|
||||
Created: time.Now().Unix(),
|
||||
CreatedBy: tokenData.ID,
|
||||
Type: payload.Type,
|
||||
}
|
||||
|
||||
schedules, err := handler.dataStore.EdgeUpdateSchedule().List()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to list edge update schedules", err)
|
||||
}
|
||||
|
||||
prevVersions := map[portainer.EndpointID]string{}
|
||||
if item.Type == edgetypes.UpdateScheduleRollback {
|
||||
prevVersions = previousVersions(schedules)
|
||||
}
|
||||
|
||||
for environmentID, version := range payload.Environments {
|
||||
environment, err := handler.dataStore.Endpoint().Endpoint(environmentID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve environment from the database", err)
|
||||
}
|
||||
|
||||
// TODO check that env is standalone (snapshots)
|
||||
if environment.Type != portainer.EdgeAgentOnDockerEnvironment {
|
||||
return httperror.BadRequest("Only standalone docker Environments are supported for remote update", nil)
|
||||
}
|
||||
|
||||
// validate version id is valid for rollback
|
||||
if item.Type == edgetypes.UpdateScheduleRollback {
|
||||
if prevVersions[environmentID] == "" {
|
||||
return httperror.BadRequest("No previous version found for environment", nil)
|
||||
}
|
||||
|
||||
if version != prevVersions[environmentID] {
|
||||
return httperror.BadRequest("Rollback version must match previous version", nil)
|
||||
}
|
||||
}
|
||||
|
||||
item.Status[environmentID] = edgetypes.UpdateScheduleStatus{
|
||||
TargetVersion: version,
|
||||
CurrentVersion: environment.Agent.Version,
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.dataStore.EdgeUpdateSchedule().Create(item)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist the edge update schedule", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, item)
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
package edgeupdateschedules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer/api/edgetypes"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
)
|
||||
|
||||
// @id EdgeUpdateScheduleDelete
|
||||
// @summary Deletes an Edge Update Schedule
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_update_schedules
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @success 204
|
||||
// @failure 500
|
||||
// @router /edge_update_schedules/{id} [delete]
|
||||
func (handler *Handler) delete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
item, err := middlewares.FetchItem[edgetypes.UpdateSchedule](r, contextKey)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
|
||||
err = handler.dataStore.EdgeUpdateSchedule().Delete(item.ID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to delete the edge update schedule", err)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
package edgeupdateschedules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer/api/edgetypes"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
)
|
||||
|
||||
// @id EdgeUpdateScheduleInspect
|
||||
// @summary Returns the Edge Update Schedule with the given ID
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_update_schedules
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @success 200 {object} edgetypes.UpdateSchedule
|
||||
// @failure 500
|
||||
// @router /edge_update_schedules/{id} [get]
|
||||
func (handler *Handler) inspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
item, err := middlewares.FetchItem[edgetypes.UpdateSchedule](r, contextKey)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
|
||||
return response.JSON(w, item)
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
package edgeupdateschedules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
)
|
||||
|
||||
// @id EdgeUpdateScheduleList
|
||||
// @summary Fetches the list of Edge Update Schedules
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_update_schedules
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @success 200 {array} edgetypes.UpdateSchedule
|
||||
// @failure 500
|
||||
// @router /edge_update_schedules [get]
|
||||
func (handler *Handler) list(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
list, err := handler.dataStore.EdgeUpdateSchedule().List()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve the edge update schedules list", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, list)
|
||||
}
|
|
@ -1,108 +0,0 @@
|
|||
package edgeupdateschedules
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/edgetypes"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
)
|
||||
|
||||
type updatePayload struct {
|
||||
Name string
|
||||
GroupIDs []portainer.EdgeGroupID
|
||||
Environments map[portainer.EndpointID]string
|
||||
Type edgetypes.UpdateScheduleType
|
||||
Time int64
|
||||
}
|
||||
|
||||
func (payload *updatePayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.Name) {
|
||||
return errors.New("Invalid tag name")
|
||||
}
|
||||
|
||||
if len(payload.GroupIDs) == 0 {
|
||||
return errors.New("Required to choose at least one group")
|
||||
}
|
||||
|
||||
if payload.Type != edgetypes.UpdateScheduleRollback && payload.Type != edgetypes.UpdateScheduleUpdate {
|
||||
return errors.New("Invalid schedule type")
|
||||
}
|
||||
|
||||
if len(payload.Environments) == 0 {
|
||||
return errors.New("No Environment is scheduled for update")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id EdgeUpdateScheduleUpdate
|
||||
// @summary Updates an Edge Update Schedule
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_update_schedules
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @param body body updatePayload true "Schedule details"
|
||||
// @produce json
|
||||
// @success 204
|
||||
// @failure 500
|
||||
// @router /edge_update_schedules [post]
|
||||
func (handler *Handler) update(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
item, err := middlewares.FetchItem[edgetypes.UpdateSchedule](r, contextKey)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError(err.Error(), err)
|
||||
}
|
||||
|
||||
var payload updatePayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
if payload.Name != item.Name {
|
||||
err = handler.validateUniqueName(payload.Name, item.ID)
|
||||
if err != nil {
|
||||
return httperror.NewError(http.StatusConflict, "Edge update schedule name already in use", err)
|
||||
}
|
||||
|
||||
item.Name = payload.Name
|
||||
}
|
||||
|
||||
// if scheduled time didn't passed, then can update the schedule
|
||||
if item.Time > time.Now().Unix() {
|
||||
item.GroupIDs = payload.GroupIDs
|
||||
item.Time = payload.Time
|
||||
item.Type = payload.Type
|
||||
|
||||
item.Status = map[portainer.EndpointID]edgetypes.UpdateScheduleStatus{}
|
||||
for environmentID, version := range payload.Environments {
|
||||
environment, err := handler.dataStore.Endpoint().Endpoint(environmentID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve environment from the database", err)
|
||||
}
|
||||
|
||||
if environment.Type != portainer.EdgeAgentOnDockerEnvironment {
|
||||
return httperror.BadRequest("Only standalone docker Environments are supported for remote update", nil)
|
||||
}
|
||||
|
||||
item.Status[environmentID] = edgetypes.UpdateScheduleStatus{
|
||||
TargetVersion: version,
|
||||
CurrentVersion: environment.Agent.Version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.dataStore.EdgeUpdateSchedule().Update(item.ID, item)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to persist the edge update schedule", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, item)
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
package edgeupdateschedules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/edgetypes"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
const contextKey = "edgeUpdateSchedule_item"
|
||||
|
||||
// Handler is the HTTP handler used to handle edge environment update operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
requestBouncer *security.RequestBouncer
|
||||
dataStore dataservices.DataStore
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage environment update operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
dataStore: dataStore,
|
||||
}
|
||||
|
||||
router := h.PathPrefix("/edge_update_schedules").Subrouter()
|
||||
router.Use(bouncer.AdminAccess)
|
||||
router.Use(middlewares.FeatureFlag(dataStore.Settings(), portainer.FeatureFlagEdgeRemoteUpdate))
|
||||
|
||||
router.Handle("",
|
||||
httperror.LoggerHandler(h.list)).Methods(http.MethodGet)
|
||||
|
||||
router.Handle("",
|
||||
httperror.LoggerHandler(h.create)).Methods(http.MethodPost)
|
||||
|
||||
router.Handle("/active",
|
||||
httperror.LoggerHandler(h.activeSchedules)).Methods(http.MethodPost)
|
||||
|
||||
router.Handle("/agent_versions",
|
||||
httperror.LoggerHandler(h.agentVersions)).Methods(http.MethodGet)
|
||||
|
||||
router.Handle("/previous_versions",
|
||||
httperror.LoggerHandler(h.previousVersions)).Methods(http.MethodGet)
|
||||
|
||||
itemRouter := router.PathPrefix("/{id}").Subrouter()
|
||||
itemRouter.Use(middlewares.WithItem(func(id edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error) {
|
||||
return dataStore.EdgeUpdateSchedule().Item(id)
|
||||
}, "id", contextKey))
|
||||
|
||||
itemRouter.Handle("",
|
||||
httperror.LoggerHandler(h.inspect)).Methods(http.MethodGet)
|
||||
|
||||
itemRouter.Handle("",
|
||||
httperror.LoggerHandler(h.update)).Methods(http.MethodPut)
|
||||
|
||||
itemRouter.Handle("",
|
||||
httperror.LoggerHandler(h.delete)).Methods(http.MethodDelete)
|
||||
|
||||
return h
|
||||
}
|
|
@ -1,86 +0,0 @@
|
|||
package edgeupdateschedules
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/edgetypes"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// @id EdgeUpdatePreviousVersions
|
||||
// @summary Fetches the previous versions of updated agents
|
||||
// @description
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags edge_update_schedules
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @success 200 {array} string
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /edge_update_schedules/agent_versions [get]
|
||||
func (handler *Handler) previousVersions(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
schedules, err := handler.dataStore.EdgeUpdateSchedule().List()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve the edge update schedules list", err)
|
||||
}
|
||||
|
||||
versionMap := previousVersions(schedules)
|
||||
|
||||
return response.JSON(w, versionMap)
|
||||
}
|
||||
|
||||
type EnvironmentVersionDetails struct {
|
||||
version string
|
||||
skip bool
|
||||
skipReason string
|
||||
}
|
||||
|
||||
func previousVersions(schedules []edgetypes.UpdateSchedule) map[portainer.EndpointID]string {
|
||||
|
||||
slices.SortFunc(schedules, func(a edgetypes.UpdateSchedule, b edgetypes.UpdateSchedule) bool {
|
||||
return a.Created > b.Created
|
||||
})
|
||||
|
||||
environmentMap := map[portainer.EndpointID]*EnvironmentVersionDetails{}
|
||||
// to all schedules[:schedule index -1].Created > schedule.Created
|
||||
for _, schedule := range schedules {
|
||||
for environmentId, status := range schedule.Status {
|
||||
props, ok := environmentMap[environmentId]
|
||||
if !ok {
|
||||
environmentMap[environmentId] = &EnvironmentVersionDetails{}
|
||||
props = environmentMap[environmentId]
|
||||
}
|
||||
|
||||
if props.version != "" || props.skip {
|
||||
continue
|
||||
}
|
||||
|
||||
if schedule.Type == edgetypes.UpdateScheduleRollback {
|
||||
props.skip = true
|
||||
props.skipReason = "has rollback"
|
||||
continue
|
||||
}
|
||||
|
||||
if status.Status == edgetypes.UpdateScheduleStatusPending || status.Status == edgetypes.UpdateScheduleStatusError {
|
||||
props.skip = true
|
||||
props.skipReason = "has active schedule"
|
||||
continue
|
||||
}
|
||||
|
||||
props.version = status.CurrentVersion
|
||||
}
|
||||
}
|
||||
|
||||
versionMap := map[portainer.EndpointID]string{}
|
||||
for environmentId, props := range environmentMap {
|
||||
if !props.skip {
|
||||
versionMap[environmentId] = props.version
|
||||
}
|
||||
}
|
||||
|
||||
return versionMap
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
package edgeupdateschedules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/edgetypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPreviousVersions(t *testing.T) {
|
||||
|
||||
schedules := []edgetypes.UpdateSchedule{
|
||||
{
|
||||
ID: 1,
|
||||
Type: edgetypes.UpdateScheduleUpdate,
|
||||
Status: map[portainer.EndpointID]edgetypes.UpdateScheduleStatus{
|
||||
1: {
|
||||
TargetVersion: "2.14.0",
|
||||
CurrentVersion: "2.11.0",
|
||||
Status: edgetypes.UpdateScheduleStatusSuccess,
|
||||
},
|
||||
2: {
|
||||
TargetVersion: "2.13.0",
|
||||
CurrentVersion: "2.12.0",
|
||||
Status: edgetypes.UpdateScheduleStatusSuccess,
|
||||
},
|
||||
},
|
||||
Created: 1500000000,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Type: edgetypes.UpdateScheduleRollback,
|
||||
Status: map[portainer.EndpointID]edgetypes.UpdateScheduleStatus{
|
||||
1: {
|
||||
TargetVersion: "2.11.0",
|
||||
CurrentVersion: "2.14.0",
|
||||
Status: edgetypes.UpdateScheduleStatusSuccess,
|
||||
},
|
||||
},
|
||||
Created: 1500000001,
|
||||
},
|
||||
{
|
||||
ID: 3,
|
||||
Type: edgetypes.UpdateScheduleUpdate,
|
||||
Status: map[portainer.EndpointID]edgetypes.UpdateScheduleStatus{
|
||||
2: {
|
||||
TargetVersion: "2.14.0",
|
||||
CurrentVersion: "2.13.0",
|
||||
Status: edgetypes.UpdateScheduleStatusSuccess,
|
||||
},
|
||||
},
|
||||
Created: 1500000002,
|
||||
},
|
||||
}
|
||||
|
||||
actual := previousVersions(schedules)
|
||||
|
||||
assert.Equal(t, map[portainer.EndpointID]string{
|
||||
2: "2.13.0",
|
||||
}, actual)
|
||||
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
package edgeupdateschedules
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/edgetypes"
|
||||
)
|
||||
|
||||
func (handler *Handler) validateUniqueName(name string, id edgetypes.UpdateScheduleID) error {
|
||||
list, err := handler.dataStore.EdgeUpdateSchedule().List()
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "Unable to list edge update schedules")
|
||||
}
|
||||
|
||||
for _, schedule := range list {
|
||||
if id != schedule.ID && schedule.Name == name {
|
||||
return errors.New("Edge update schedule name already in use")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -12,7 +12,6 @@ import (
|
|||
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
||||
"github.com/portainer/portainer/api/http/handler/edgeupdateschedules"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
||||
|
@ -44,43 +43,42 @@ import (
|
|||
|
||||
// Handler is a collection of all the service handlers.
|
||||
type Handler struct {
|
||||
AuthHandler *auth.Handler
|
||||
BackupHandler *backup.Handler
|
||||
CustomTemplatesHandler *customtemplates.Handler
|
||||
DockerHandler *docker.Handler
|
||||
EdgeGroupsHandler *edgegroups.Handler
|
||||
EdgeJobsHandler *edgejobs.Handler
|
||||
EdgeUpdateScheduleHandler *edgeupdateschedules.Handler
|
||||
EdgeStacksHandler *edgestacks.Handler
|
||||
EdgeTemplatesHandler *edgetemplates.Handler
|
||||
EndpointEdgeHandler *endpointedge.Handler
|
||||
EndpointGroupHandler *endpointgroups.Handler
|
||||
EndpointHandler *endpoints.Handler
|
||||
EndpointHelmHandler *helm.Handler
|
||||
EndpointProxyHandler *endpointproxy.Handler
|
||||
HelmTemplatesHandler *helm.Handler
|
||||
KubernetesHandler *kubernetes.Handler
|
||||
FileHandler *file.Handler
|
||||
LDAPHandler *ldap.Handler
|
||||
MOTDHandler *motd.Handler
|
||||
RegistryHandler *registries.Handler
|
||||
ResourceControlHandler *resourcecontrols.Handler
|
||||
RoleHandler *roles.Handler
|
||||
SettingsHandler *settings.Handler
|
||||
SSLHandler *ssl.Handler
|
||||
OpenAMTHandler *openamt.Handler
|
||||
FDOHandler *fdo.Handler
|
||||
StackHandler *stacks.Handler
|
||||
StatusHandler *status.Handler
|
||||
StorybookHandler *storybook.Handler
|
||||
TagHandler *tags.Handler
|
||||
TeamMembershipHandler *teammemberships.Handler
|
||||
TeamHandler *teams.Handler
|
||||
TemplatesHandler *templates.Handler
|
||||
UploadHandler *upload.Handler
|
||||
UserHandler *users.Handler
|
||||
WebSocketHandler *websocket.Handler
|
||||
WebhookHandler *webhooks.Handler
|
||||
AuthHandler *auth.Handler
|
||||
BackupHandler *backup.Handler
|
||||
CustomTemplatesHandler *customtemplates.Handler
|
||||
DockerHandler *docker.Handler
|
||||
EdgeGroupsHandler *edgegroups.Handler
|
||||
EdgeJobsHandler *edgejobs.Handler
|
||||
EdgeStacksHandler *edgestacks.Handler
|
||||
EdgeTemplatesHandler *edgetemplates.Handler
|
||||
EndpointEdgeHandler *endpointedge.Handler
|
||||
EndpointGroupHandler *endpointgroups.Handler
|
||||
EndpointHandler *endpoints.Handler
|
||||
EndpointHelmHandler *helm.Handler
|
||||
EndpointProxyHandler *endpointproxy.Handler
|
||||
HelmTemplatesHandler *helm.Handler
|
||||
KubernetesHandler *kubernetes.Handler
|
||||
FileHandler *file.Handler
|
||||
LDAPHandler *ldap.Handler
|
||||
MOTDHandler *motd.Handler
|
||||
RegistryHandler *registries.Handler
|
||||
ResourceControlHandler *resourcecontrols.Handler
|
||||
RoleHandler *roles.Handler
|
||||
SettingsHandler *settings.Handler
|
||||
SSLHandler *ssl.Handler
|
||||
OpenAMTHandler *openamt.Handler
|
||||
FDOHandler *fdo.Handler
|
||||
StackHandler *stacks.Handler
|
||||
StatusHandler *status.Handler
|
||||
StorybookHandler *storybook.Handler
|
||||
TagHandler *tags.Handler
|
||||
TeamMembershipHandler *teammemberships.Handler
|
||||
TeamHandler *teams.Handler
|
||||
TemplatesHandler *templates.Handler
|
||||
UploadHandler *upload.Handler
|
||||
UserHandler *users.Handler
|
||||
WebSocketHandler *websocket.Handler
|
||||
WebhookHandler *webhooks.Handler
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
|
@ -169,8 +167,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/custom_templates"):
|
||||
http.StripPrefix("/api", h.CustomTemplatesHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/edge_update_schedules"):
|
||||
http.StripPrefix("/api", h.EdgeUpdateScheduleHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"):
|
||||
http.StripPrefix("/api", h.EdgeStacksHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/edge_groups"):
|
||||
|
|
|
@ -23,7 +23,6 @@ import (
|
|||
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
||||
"github.com/portainer/portainer/api/http/handler/edgeupdateschedules"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
||||
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
||||
|
@ -56,6 +55,7 @@ import (
|
|||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
"github.com/portainer/portainer/api/internal/ssl"
|
||||
k8s "github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
@ -77,6 +77,7 @@ type Server struct {
|
|||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
ComposeStackManager portainer.ComposeStackManager
|
||||
CryptoService portainer.CryptoService
|
||||
EdgeStacksService *edgestackservice.Service
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
SnapshotService portainer.SnapshotService
|
||||
FileService portainer.FileService
|
||||
|
@ -153,9 +154,7 @@ func (server *Server) Start() error {
|
|||
edgeJobsHandler.FileService = server.FileService
|
||||
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
|
||||
edgeUpdateScheduleHandler := edgeupdateschedules.NewHandler(requestBouncer, server.DataStore)
|
||||
|
||||
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore)
|
||||
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService)
|
||||
edgeStacksHandler.FileService = server.FileService
|
||||
edgeStacksHandler.GitService = server.GitService
|
||||
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
|
||||
|
@ -277,43 +276,42 @@ func (server *Server) Start() error {
|
|||
webhookHandler.DockerClientFactory = server.DockerClientFactory
|
||||
|
||||
server.Handler = &handler.Handler{
|
||||
RoleHandler: roleHandler,
|
||||
AuthHandler: authHandler,
|
||||
BackupHandler: backupHandler,
|
||||
CustomTemplatesHandler: customTemplatesHandler,
|
||||
DockerHandler: dockerHandler,
|
||||
EdgeGroupsHandler: edgeGroupsHandler,
|
||||
EdgeJobsHandler: edgeJobsHandler,
|
||||
EdgeUpdateScheduleHandler: edgeUpdateScheduleHandler,
|
||||
EdgeStacksHandler: edgeStacksHandler,
|
||||
EdgeTemplatesHandler: edgeTemplatesHandler,
|
||||
EndpointGroupHandler: endpointGroupHandler,
|
||||
EndpointHandler: endpointHandler,
|
||||
EndpointHelmHandler: endpointHelmHandler,
|
||||
EndpointEdgeHandler: endpointEdgeHandler,
|
||||
EndpointProxyHandler: endpointProxyHandler,
|
||||
FileHandler: fileHandler,
|
||||
LDAPHandler: ldapHandler,
|
||||
HelmTemplatesHandler: helmTemplatesHandler,
|
||||
KubernetesHandler: kubernetesHandler,
|
||||
MOTDHandler: motdHandler,
|
||||
OpenAMTHandler: openAMTHandler,
|
||||
FDOHandler: fdoHandler,
|
||||
RegistryHandler: registryHandler,
|
||||
ResourceControlHandler: resourceControlHandler,
|
||||
SettingsHandler: settingsHandler,
|
||||
SSLHandler: sslHandler,
|
||||
StatusHandler: statusHandler,
|
||||
StackHandler: stackHandler,
|
||||
StorybookHandler: storybookHandler,
|
||||
TagHandler: tagHandler,
|
||||
TeamHandler: teamHandler,
|
||||
TeamMembershipHandler: teamMembershipHandler,
|
||||
TemplatesHandler: templatesHandler,
|
||||
UploadHandler: uploadHandler,
|
||||
UserHandler: userHandler,
|
||||
WebSocketHandler: websocketHandler,
|
||||
WebhookHandler: webhookHandler,
|
||||
RoleHandler: roleHandler,
|
||||
AuthHandler: authHandler,
|
||||
BackupHandler: backupHandler,
|
||||
CustomTemplatesHandler: customTemplatesHandler,
|
||||
DockerHandler: dockerHandler,
|
||||
EdgeGroupsHandler: edgeGroupsHandler,
|
||||
EdgeJobsHandler: edgeJobsHandler,
|
||||
EdgeStacksHandler: edgeStacksHandler,
|
||||
EdgeTemplatesHandler: edgeTemplatesHandler,
|
||||
EndpointGroupHandler: endpointGroupHandler,
|
||||
EndpointHandler: endpointHandler,
|
||||
EndpointHelmHandler: endpointHelmHandler,
|
||||
EndpointEdgeHandler: endpointEdgeHandler,
|
||||
EndpointProxyHandler: endpointProxyHandler,
|
||||
FileHandler: fileHandler,
|
||||
LDAPHandler: ldapHandler,
|
||||
HelmTemplatesHandler: helmTemplatesHandler,
|
||||
KubernetesHandler: kubernetesHandler,
|
||||
MOTDHandler: motdHandler,
|
||||
OpenAMTHandler: openAMTHandler,
|
||||
FDOHandler: fdoHandler,
|
||||
RegistryHandler: registryHandler,
|
||||
ResourceControlHandler: resourceControlHandler,
|
||||
SettingsHandler: settingsHandler,
|
||||
SSLHandler: sslHandler,
|
||||
StatusHandler: statusHandler,
|
||||
StackHandler: stackHandler,
|
||||
StorybookHandler: storybookHandler,
|
||||
TagHandler: tagHandler,
|
||||
TeamHandler: teamHandler,
|
||||
TeamMembershipHandler: teamMembershipHandler,
|
||||
TemplatesHandler: templatesHandler,
|
||||
UploadHandler: uploadHandler,
|
||||
UserHandler: userHandler,
|
||||
WebSocketHandler: websocketHandler,
|
||||
WebhookHandler: webhookHandler,
|
||||
}
|
||||
|
||||
handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler))
|
||||
|
|
|
@ -2,8 +2,10 @@ package edge
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
|
||||
// EdgeStackRelatedEndpoints returns a list of environments(endpoints) related to this Edge stack
|
||||
|
@ -29,3 +31,33 @@ func EdgeStackRelatedEndpoints(edgeGroupIDs []portainer.EdgeGroupID, endpoints [
|
|||
|
||||
return edgeStackEndpoints, nil
|
||||
}
|
||||
|
||||
type EndpointRelationsConfig struct {
|
||||
Endpoints []portainer.Endpoint
|
||||
EndpointGroups []portainer.EndpointGroup
|
||||
EdgeGroups []portainer.EdgeGroup
|
||||
}
|
||||
|
||||
// FetchEndpointRelationsConfig fetches config needed for Edge Stack related endpoints
|
||||
func FetchEndpointRelationsConfig(dataStore dataservices.DataStore) (*EndpointRelationsConfig, error) {
|
||||
endpoints, err := dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve environments from database: %w", err)
|
||||
}
|
||||
|
||||
endpointGroups, err := dataStore.EndpointGroup().EndpointGroups()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve environment groups from database: %w", err)
|
||||
}
|
||||
|
||||
edgeGroups, err := dataStore.EdgeGroup().EdgeGroups()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve edge groups from database: %w", err)
|
||||
}
|
||||
|
||||
return &EndpointRelationsConfig{
|
||||
Endpoints: endpoints,
|
||||
EndpointGroups: endpointGroups,
|
||||
EdgeGroups: edgeGroups,
|
||||
}, nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,160 @@
|
|||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
|
||||
)
|
||||
|
||||
// Service represents a service for managing edge stacks.
|
||||
type Service struct {
|
||||
dataStore dataservices.DataStore
|
||||
}
|
||||
|
||||
// NewService returns a new instance of a service.
|
||||
func NewService(dataStore dataservices.DataStore) *Service {
|
||||
return &Service{
|
||||
dataStore: dataStore,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildEdgeStack builds the initial edge stack object
|
||||
// PersistEdgeStack is required to be called after this to persist the edge stack
|
||||
func (service *Service) BuildEdgeStack(name string,
|
||||
deploymentType portainer.EdgeStackDeploymentType,
|
||||
edgeGroups []portainer.EdgeGroupID,
|
||||
registries []portainer.RegistryID) (*portainer.EdgeStack, error) {
|
||||
edgeStacksService := service.dataStore.EdgeStack()
|
||||
|
||||
err := validateUniqueName(edgeStacksService.EdgeStacks, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stackID := edgeStacksService.GetNextIdentifier()
|
||||
return &portainer.EdgeStack{
|
||||
ID: portainer.EdgeStackID(stackID),
|
||||
Name: name,
|
||||
DeploymentType: deploymentType,
|
||||
CreationDate: time.Now().Unix(),
|
||||
EdgeGroups: edgeGroups,
|
||||
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
|
||||
Version: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateUniqueName(edgeStacksGetter func() ([]portainer.EdgeStack, error), name string) error {
|
||||
edgeStacks, err := edgeStacksGetter()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, stack := range edgeStacks {
|
||||
if strings.EqualFold(stack.Name, name) {
|
||||
return errors.New("Edge stack name must be unique")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistEdgeStack persists the edge stack in the database and its relations
|
||||
func (service *Service) PersistEdgeStack(
|
||||
stack *portainer.EdgeStack,
|
||||
storeManifest edgetypes.StoreManifestFunc) (*portainer.EdgeStack, error) {
|
||||
|
||||
relationConfig, err := edge.FetchEndpointRelationsConfig(service.dataStore)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to find environment relations in database: %w", err)
|
||||
}
|
||||
|
||||
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to persist environment relation in database: %w", err)
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
composePath, manifestPath, projectPath, err := storeManifest(stackFolder, relatedEndpointIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to store manifest: %w", err)
|
||||
}
|
||||
|
||||
stack.ManifestPath = manifestPath
|
||||
stack.ProjectPath = projectPath
|
||||
stack.EntryPoint = composePath
|
||||
|
||||
err = service.updateEndpointRelations(stack.ID, relatedEndpointIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to update endpoint relations: %w", err)
|
||||
}
|
||||
|
||||
err = service.dataStore.EdgeStack().Create(stack.ID, stack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
// updateEndpointRelations adds a relation between the Edge Stack to the related environments(endpoints)
|
||||
func (service *Service) updateEndpointRelations(edgeStackID portainer.EdgeStackID, relatedEndpointIds []portainer.EndpointID) error {
|
||||
endpointRelationService := service.dataStore.EndpointRelation()
|
||||
|
||||
for _, endpointID := range relatedEndpointIds {
|
||||
relation, err := endpointRelationService.EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
|
||||
}
|
||||
|
||||
relation.EdgeStacks[edgeStackID] = true
|
||||
|
||||
err = endpointRelationService.UpdateEndpointRelation(endpointID, relation)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to persist endpoint relation in database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEdgeStack deletes the edge stack from the database and its relations
|
||||
func (service *Service) DeleteEdgeStack(edgeStackID portainer.EdgeStackID, relatedEdgeGroupsIds []portainer.EdgeGroupID) error {
|
||||
|
||||
relationConfig, err := edge.FetchEndpointRelationsConfig(service.dataStore)
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "Unable to retrieve environments relations config from database")
|
||||
}
|
||||
|
||||
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(relatedEdgeGroupsIds, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||
}
|
||||
|
||||
for _, endpointID := range relatedEndpointIds {
|
||||
relation, err := service.dataStore.EndpointRelation().EndpointRelation(endpointID)
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "Unable to find environment relation in database")
|
||||
}
|
||||
|
||||
delete(relation.EdgeStacks, edgeStackID)
|
||||
|
||||
err = service.dataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "Unable to persist environment relation in database")
|
||||
}
|
||||
}
|
||||
|
||||
err = service.dataStore.EdgeStack().DeleteEdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "Unable to remove the edge stack from the database")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -23,7 +23,9 @@ func Test_updateEndpointRelation_successfulRuns(t *testing.T) {
|
|||
|
||||
dataStore := testhelpers.NewDatastore(testhelpers.WithEndpointRelations(endpointRelations))
|
||||
|
||||
err := updateEndpointRelations(dataStore.EndpointRelation(), edgeStackID, relatedIds)
|
||||
service := NewService(dataStore)
|
||||
|
||||
err := service.updateEndpointRelations(edgeStackID, relatedIds)
|
||||
|
||||
assert.NoError(t, err, "updateEndpointRelations should not fail")
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package types
|
||||
|
||||
import portainer "github.com/portainer/portainer/api"
|
||||
|
||||
type StoreManifestFunc func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath, manifestPath, projectPath string, err error)
|
|
@ -12,7 +12,6 @@ type testDatastore struct {
|
|||
customTemplate dataservices.CustomTemplateService
|
||||
edgeGroup dataservices.EdgeGroupService
|
||||
edgeJob dataservices.EdgeJobService
|
||||
edgeUpdateSchedule dataservices.EdgeUpdateScheduleService
|
||||
edgeStack dataservices.EdgeStackService
|
||||
endpoint dataservices.EndpointService
|
||||
endpointGroup dataservices.EndpointGroupService
|
||||
|
@ -49,9 +48,7 @@ func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { re
|
|||
func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
|
||||
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
|
||||
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
|
||||
func (d *testDatastore) EdgeUpdateSchedule() dataservices.EdgeUpdateScheduleService {
|
||||
return d.edgeUpdateSchedule
|
||||
}
|
||||
|
||||
func (d *testDatastore) FDOProfile() dataservices.FDOProfileService {
|
||||
return d.fdoProfile
|
||||
}
|
||||
|
|
|
@ -1500,12 +1500,10 @@ const (
|
|||
WebSocketKeepAlive = 1 * time.Hour
|
||||
)
|
||||
|
||||
const FeatureFlagEdgeRemoteUpdate Feature = "edgeRemoteUpdate"
|
||||
const FeatureFlagBEUpgrade = "beUpgrade"
|
||||
|
||||
// List of supported features
|
||||
var SupportedFeatureFlags = []Feature{
|
||||
FeatureFlagEdgeRemoteUpdate,
|
||||
FeatureFlagBEUpgrade,
|
||||
}
|
||||
|
||||
|
|
|
@ -37,13 +37,18 @@ export function humanize(bytes, round, base) {
|
|||
return filesize(bytes, { base: base, round: round });
|
||||
}
|
||||
}
|
||||
export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
export function isoDateFromTimestamp(timestamp) {
|
||||
return moment.unix(timestamp).format('YYYY-MM-DD HH:mm:ss');
|
||||
return moment.unix(timestamp).format(TIME_FORMAT);
|
||||
}
|
||||
|
||||
export function isoDate(date) {
|
||||
return moment(date).format('YYYY-MM-DD HH:mm:ss');
|
||||
return moment(date).format(TIME_FORMAT);
|
||||
}
|
||||
|
||||
export function parseIsoDate(date) {
|
||||
return moment(date, TIME_FORMAT).toDate();
|
||||
}
|
||||
|
||||
export function getPairKey(pair, separator) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { PropsWithChildren, AnchorHTMLAttributes } from 'react';
|
||||
import { UISref, UISrefProps } from '@uirouter/react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
|
@ -15,7 +14,7 @@ export function Link({
|
|||
}: PropsWithChildren<Props> & UISrefProps) {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<UISref className={clsx('no-decoration', className)} {...props}>
|
||||
<UISref className={className} {...props}>
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||
<a title={title} target={props.target}>
|
||||
{children}
|
||||
|
|
|
@ -61,9 +61,7 @@ export function Button({
|
|||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...ariaProps}
|
||||
>
|
||||
{icon && (
|
||||
<Icon icon={icon} size={getIconSize(size)} className="inline-flex" />
|
||||
)}
|
||||
{icon && <Icon icon={icon} size={getIconSize(size)} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
import { EdgeTypes } from '@/react/portainer/environments/types';
|
||||
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
@ -9,8 +8,9 @@ import { PageHeader } from '@@/PageHeader';
|
|||
|
||||
import { Datatable } from './Datatable';
|
||||
|
||||
export function WaitingRoomView() {
|
||||
const router = useRouter();
|
||||
export default withLimitToBE(WaitingRoomView);
|
||||
|
||||
function WaitingRoomView() {
|
||||
const { environments, isLoading, totalCount } = useEnvironmentList({
|
||||
edgeDevice: true,
|
||||
edgeDeviceUntrusted: true,
|
||||
|
@ -18,11 +18,6 @@ export function WaitingRoomView() {
|
|||
types: EdgeTypes,
|
||||
});
|
||||
|
||||
if (process.env.PORTAINER_EDITION !== 'BE') {
|
||||
router.stateService.go('edge.devices');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
|
|
|
@ -1 +1 @@
|
|||
export { WaitingRoomView } from './WaitingRoomView';
|
||||
export { default as WaitingRoomView } from './WaitingRoomView';
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
export function useLimitToBE(defaultPath = 'portainer.home') {
|
||||
const router = useRouter();
|
||||
if (!isBE) {
|
||||
router.stateService.go(defaultPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function withLimitToBE<T>(
|
||||
WrappedComponent: ComponentType<T>,
|
||||
defaultPath = 'portainer.home'
|
||||
): ComponentType<T> {
|
||||
// Try to create a nice displayName for React Dev Tools.
|
||||
const displayName =
|
||||
WrappedComponent.displayName || WrappedComponent.name || 'Component';
|
||||
|
||||
function WrapperComponent(props: T) {
|
||||
const isLimitedToBE = useLimitToBE(defaultPath);
|
||||
|
||||
if (isLimitedToBE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return <WrappedComponent {...props} />;
|
||||
}
|
||||
|
||||
WrapperComponent.displayName = `withLimitToBE(${displayName})`;
|
||||
|
||||
return WrapperComponent;
|
||||
}
|
|
@ -81,7 +81,10 @@ export function IngressDatatable() {
|
|||
</Authorized>
|
||||
|
||||
<Authorized authorizations="K8sIngressesW">
|
||||
<Link to="kubernetes.ingresses.create" className="space-left">
|
||||
<Link
|
||||
to="kubernetes.ingresses.create"
|
||||
className="space-left no-decoration"
|
||||
>
|
||||
<Button
|
||||
icon={Plus}
|
||||
className="btn-wrapper vertical-center"
|
||||
|
@ -92,7 +95,7 @@ export function IngressDatatable() {
|
|||
</Link>
|
||||
</Authorized>
|
||||
<Authorized authorizations="K8sIngressesW">
|
||||
<Link to="kubernetes.deploy" className="space-left">
|
||||
<Link to="kubernetes.deploy" className="space-left no-decoration">
|
||||
<Button icon={Plus} className="btn-wrapper">
|
||||
Create from manifest
|
||||
</Button>
|
||||
|
|
|
@ -40,6 +40,7 @@ import { EnvironmentItem } from './EnvironmentItem';
|
|||
import { KubeconfigButton } from './KubeconfigButton';
|
||||
import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel';
|
||||
import styles from './EnvironmentList.module.css';
|
||||
import { UpdateBadge } from './UpdateBadge';
|
||||
|
||||
interface Props {
|
||||
onClickItem(environment: Environment): void;
|
||||
|
@ -131,21 +132,27 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||
edgeDevice: false,
|
||||
tagsPartialMatch: true,
|
||||
agentVersions: agentVersions.map((a) => a.value),
|
||||
updateInformation: isBE,
|
||||
};
|
||||
|
||||
const tagsQuery = useTags();
|
||||
|
||||
const { isLoading, environments, totalCount, totalAvailable } =
|
||||
useEnvironmentList(
|
||||
{
|
||||
page,
|
||||
pageLimit,
|
||||
sort: sortByFilter,
|
||||
order: sortByDescending ? 'desc' : 'asc',
|
||||
...environmentsQueryParams,
|
||||
},
|
||||
refetchIfAnyOffline
|
||||
);
|
||||
const {
|
||||
isLoading,
|
||||
environments,
|
||||
totalCount,
|
||||
totalAvailable,
|
||||
updateAvailable,
|
||||
} = useEnvironmentList(
|
||||
{
|
||||
page,
|
||||
pageLimit,
|
||||
sort: sortByFilter,
|
||||
order: sortByDescending ? 'desc' : 'asc',
|
||||
...environmentsQueryParams,
|
||||
},
|
||||
refetchIfAnyOffline
|
||||
);
|
||||
|
||||
const agentVersionsQuery = useAgentVersionsList();
|
||||
|
||||
|
@ -175,9 +182,10 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||
return (
|
||||
<>
|
||||
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
|
||||
|
||||
<TableContainer>
|
||||
<TableTitle icon={HardDrive} label="Environments" />
|
||||
<TableTitle icon={HardDrive} label="Environments">
|
||||
{isBE && updateAvailable && <UpdateBadge />}
|
||||
</TableTitle>
|
||||
|
||||
<TableActions className={styles.actionBar}>
|
||||
<div className={styles.description}>
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import _ from 'lodash';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { useSupportedAgentVersions } from '@/react/portainer/environments/update-schedules/queries/useSupportedAgentVersions';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
export function UpdateBadge() {
|
||||
const version = useAgentLatestVersion();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'badge inline-flex items-center px-3 py-2 font-normal border-solid border border-transparent',
|
||||
'bg-blue-3 text-blue-8',
|
||||
'th-dark:bg-blue-8 th-dark:text-white',
|
||||
'th-highcontrast:bg-transparent th-highcontrast:text-white th-highcontrast:border-white'
|
||||
)}
|
||||
>
|
||||
Update Available: Edge Agent {version}
|
||||
<Link
|
||||
to="portainer.endpoints.updateSchedules.create"
|
||||
className={clsx(
|
||||
'badge font-normal ml-2 border-solid border border-transparent',
|
||||
'bg-blue-8 text-blue-3',
|
||||
'th-dark:bg-blue-3 th-dark:text-blue-8 th-dark:hover:bg-blue-5 th-dark:hover:text-blue-8',
|
||||
'th-highcontrast:bg-transparent th-highcontrast:text-white th-highcontrast:hover:bg-gray-warm-7 th-highcontrast:hover:text-white th-highcontrast:border-white'
|
||||
)}
|
||||
>
|
||||
Schedule Update
|
||||
</Link>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useAgentLatestVersion() {
|
||||
const supportedAgentVersionsQuery = useSupportedAgentVersions();
|
||||
|
||||
return _.last(supportedAgentVersionsQuery.data) || '';
|
||||
}
|
|
@ -28,6 +28,7 @@ export interface EnvironmentsQueryParams {
|
|||
provisioned?: boolean;
|
||||
name?: string;
|
||||
agentVersions?: string[];
|
||||
updateInformation?: boolean;
|
||||
}
|
||||
|
||||
export interface GetEnvironmentsOptions {
|
||||
|
@ -46,7 +47,12 @@ export async function getEnvironments(
|
|||
}: GetEnvironmentsOptions = { query: {} }
|
||||
) {
|
||||
if (query.tagIds && query.tagIds.length === 0) {
|
||||
return { totalCount: 0, value: <Environment[]>[] };
|
||||
return {
|
||||
totalCount: 0,
|
||||
value: <Environment[]>[],
|
||||
totalAvailable: 0,
|
||||
updateAvailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const url = buildUrl();
|
||||
|
@ -63,11 +69,13 @@ export async function getEnvironments(
|
|||
const response = await axios.get<Environment[]>(url, { params });
|
||||
const totalCount = response.headers['x-total-count'];
|
||||
const totalAvailable = response.headers['x-total-available'];
|
||||
const updateAvailable = response.headers['x-update-available'] === 'true';
|
||||
|
||||
return {
|
||||
totalCount: parseInt(totalCount, 10),
|
||||
value: response.data,
|
||||
totalAvailable: parseInt(totalAvailable, 10),
|
||||
updateAvailable,
|
||||
};
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
|
|
|
@ -78,5 +78,6 @@ export function useEnvironmentList(
|
|||
environments: data ? data.value : [],
|
||||
totalCount: data ? data.totalCount : 0,
|
||||
totalAvailable: data ? data.totalAvailable : 0,
|
||||
updateAvailable: data ? data.updateAvailable : false,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -131,6 +131,7 @@ export type Environment = {
|
|||
Edge: EnvironmentEdge;
|
||||
SecuritySettings: EnvironmentSecuritySettings;
|
||||
Gpus: { name: string; value: string }[];
|
||||
LocalTimeZone?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -3,14 +3,13 @@ import { Formik, Form as FormikForm } from 'formik';
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import {
|
||||
useRedirectFeatureFlag,
|
||||
FeatureFlag,
|
||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
||||
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||
import { isoDate } from '@/portainer/filters/filters';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { ScheduleType } from '../types';
|
||||
import { useCreateMutation } from '../queries/create';
|
||||
|
@ -18,19 +17,21 @@ import { FormValues } from '../common/types';
|
|||
import { validation } from '../common/validation';
|
||||
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||
import { useList } from '../queries/list';
|
||||
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||
import { NameField } from '../common/NameField';
|
||||
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||
import { BetaAlert } from '../common/BetaAlert';
|
||||
|
||||
const initialValues: FormValues = {
|
||||
name: '',
|
||||
groupIds: [],
|
||||
type: ScheduleType.Update,
|
||||
time: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||
environments: {},
|
||||
};
|
||||
export default withLimitToBE(CreateView);
|
||||
|
||||
function CreateView() {
|
||||
const initialValues: FormValues = {
|
||||
name: '',
|
||||
groupIds: [],
|
||||
type: ScheduleType.Update,
|
||||
version: '',
|
||||
scheduledTime: isoDate(Date.now() + 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
||||
export function CreateView() {
|
||||
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
||||
const schedulesQuery = useList();
|
||||
|
||||
const createMutation = useCreateMutation();
|
||||
|
@ -49,6 +50,8 @@ export function CreateView() {
|
|||
breadcrumbs="Edge agent update and rollback"
|
||||
/>
|
||||
|
||||
<BetaAlert />
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
|
@ -60,11 +63,25 @@ export function CreateView() {
|
|||
validateOnMount
|
||||
validationSchema={() => validation(schedules)}
|
||||
>
|
||||
{({ isValid }) => (
|
||||
{({ isValid, setFieldValue, values, handleBlur, errors }) => (
|
||||
<FormikForm className="form-horizontal">
|
||||
<NameField />
|
||||
<EdgeGroupsField />
|
||||
<EdgeGroupsField
|
||||
onChange={(value) => setFieldValue('groupIds', value)}
|
||||
value={values.groupIds}
|
||||
onBlur={handleBlur}
|
||||
error={errors.groupIds}
|
||||
/>
|
||||
|
||||
<TextTip color="blue">
|
||||
You can upgrade from any agent version to 2.17 or later
|
||||
only. You can not upgrade to an agent version prior to
|
||||
2.17 . The ability to rollback to originating version is
|
||||
for 2.15.0+ only.
|
||||
</TextTip>
|
||||
|
||||
<ScheduleTypeSelector />
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
|
|
|
@ -1 +1 @@
|
|||
export { CreateView } from './CreateView';
|
||||
export { default as CreateView } from './CreateView';
|
||||
|
|
|
@ -1,20 +1,17 @@
|
|||
import { Settings } from 'lucide-react';
|
||||
import { Formik, Form as FormikForm } from 'formik';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
import { useMemo } from 'react';
|
||||
import { object, SchemaOf } from 'yup';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import {
|
||||
useRedirectFeatureFlag,
|
||||
FeatureFlag,
|
||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
||||
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
|
||||
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||
import { useItem } from '../queries/useItem';
|
||||
import { validation } from '../common/validation';
|
||||
import { useUpdateMutation } from '../queries/useUpdateMutation';
|
||||
|
@ -23,12 +20,12 @@ import { NameField, nameValidation } from '../common/NameField';
|
|||
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||
import { EdgeUpdateSchedule } from '../types';
|
||||
import { FormValues } from '../common/types';
|
||||
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||
import { BetaAlert } from '../common/BetaAlert';
|
||||
|
||||
import { ScheduleDetails } from './ScheduleDetails';
|
||||
|
||||
export function ItemView() {
|
||||
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
||||
export default withLimitToBE(ItemView);
|
||||
|
||||
function ItemView() {
|
||||
const {
|
||||
params: { id: idParam },
|
||||
} = useCurrentStateAndParams();
|
||||
|
@ -44,31 +41,31 @@ export function ItemView() {
|
|||
const itemQuery = useItem(id);
|
||||
const schedulesQuery = useList();
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() => (itemQuery.data ? itemQuery.data.time < Date.now() / 1000 : false),
|
||||
[itemQuery.data]
|
||||
);
|
||||
|
||||
if (!itemQuery.data || !schedulesQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = itemQuery.data;
|
||||
const isScheduleActive = item.isActive;
|
||||
|
||||
const schedules = schedulesQuery.data;
|
||||
|
||||
const initialValuesActive: Partial<FormValues> = {
|
||||
name: item.name,
|
||||
};
|
||||
|
||||
const initialValues: FormValues = {
|
||||
name: item.name,
|
||||
groupIds: item.groupIds,
|
||||
groupIds: item.edgeGroupIds,
|
||||
type: item.type,
|
||||
time: item.time,
|
||||
environments: Object.fromEntries(
|
||||
Object.entries(item.status).map(([envId, status]) => [
|
||||
parseInt(envId, 10),
|
||||
status.targetVersion,
|
||||
])
|
||||
),
|
||||
version: item.version,
|
||||
scheduledTime: item.scheduledTime,
|
||||
};
|
||||
|
||||
const environmentsCount = Object.keys(
|
||||
item.environmentsPreviousVersions
|
||||
).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
|
@ -79,13 +76,17 @@ export function ItemView() {
|
|||
]}
|
||||
/>
|
||||
|
||||
<BetaAlert />
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
|
||||
<Widget.Body>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
initialValues={
|
||||
!isScheduleActive ? initialValues : initialValuesActive
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
updateMutation.mutate(
|
||||
{ id, values },
|
||||
|
@ -102,17 +103,33 @@ export function ItemView() {
|
|||
}}
|
||||
validateOnMount
|
||||
validationSchema={() =>
|
||||
updateValidation(item.id, item.time, schedules)
|
||||
updateValidation(item.id, schedules, isScheduleActive)
|
||||
}
|
||||
>
|
||||
{({ isValid }) => (
|
||||
{({ isValid, setFieldValue, values, handleBlur, errors }) => (
|
||||
<FormikForm className="form-horizontal">
|
||||
<NameField />
|
||||
|
||||
<EdgeGroupsField disabled={isDisabled} />
|
||||
<EdgeGroupsField
|
||||
disabled={isScheduleActive}
|
||||
onChange={(value) => setFieldValue('groupIds', value)}
|
||||
value={
|
||||
isScheduleActive
|
||||
? item.edgeGroupIds
|
||||
: values.groupIds || []
|
||||
}
|
||||
onBlur={handleBlur}
|
||||
error={errors.groupIds}
|
||||
/>
|
||||
|
||||
{isDisabled ? (
|
||||
<ScheduleDetails schedule={item} />
|
||||
{isScheduleActive ? (
|
||||
<InformationPanel>
|
||||
<TextTip color="blue">
|
||||
{environmentsCount} environment(s) will be updated to
|
||||
version {item.version} on {item.scheduledTime} (local
|
||||
time)
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
) : (
|
||||
<ScheduleTypeSelector />
|
||||
)}
|
||||
|
@ -141,10 +158,10 @@ export function ItemView() {
|
|||
|
||||
function updateValidation(
|
||||
itemId: EdgeUpdateSchedule['id'],
|
||||
scheduledTime: number,
|
||||
schedules: EdgeUpdateSchedule[]
|
||||
schedules: EdgeUpdateSchedule[],
|
||||
isScheduleActive: boolean
|
||||
): SchemaOf<{ name: string } | FormValues> {
|
||||
return scheduledTime > Date.now() / 1000
|
||||
return !isScheduleActive
|
||||
? validation(schedules, itemId)
|
||||
: object({ name: nameValidation(schedules, itemId) });
|
||||
}
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { NavTabs } from '@@/NavTabs';
|
||||
|
||||
import { EdgeUpdateSchedule, ScheduleType } from '../types';
|
||||
import { ScheduledTimeField } from '../common/ScheduledTimeField';
|
||||
|
||||
export function ScheduleDetails({
|
||||
schedule,
|
||||
}: {
|
||||
schedule: EdgeUpdateSchedule;
|
||||
}) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<NavTabs
|
||||
options={[
|
||||
{
|
||||
id: ScheduleType.Update,
|
||||
label: 'Update',
|
||||
children: <UpdateDetails schedule={schedule} />,
|
||||
},
|
||||
{
|
||||
id: ScheduleType.Rollback,
|
||||
label: 'Rollback',
|
||||
children: <UpdateDetails schedule={schedule} />,
|
||||
},
|
||||
]}
|
||||
selectedId={schedule.type}
|
||||
onSelect={() => {}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateDetails({ schedule }: { schedule: EdgeUpdateSchedule }) {
|
||||
const schedulesCount = Object.values(
|
||||
_.groupBy(
|
||||
schedule.status,
|
||||
(status) => `${status.currentVersion}-${status.targetVersion}`
|
||||
)
|
||||
).map((statuses) => ({
|
||||
count: statuses.length,
|
||||
currentVersion: statuses[0].currentVersion,
|
||||
targetVersion: statuses[0].targetVersion,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
{schedulesCount.map(({ count, currentVersion, targetVersion }) => (
|
||||
<div key={`${currentVersion}-${targetVersion}`}>
|
||||
{count} edge device(s) selected for{' '}
|
||||
{schedule.type === ScheduleType.Rollback ? 'rollback' : 'update'}{' '}
|
||||
from v{currentVersion} to v{targetVersion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScheduledTimeField disabled />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1 +1 @@
|
|||
export { ItemView } from './ItemView';
|
||||
export { default as ItemView } from './ItemView';
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import { Clock, Trash2 } from 'lucide-react';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import {
|
||||
FeatureFlag,
|
||||
useRedirectFeatureFlag,
|
||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
@ -15,7 +12,7 @@ import { Link } from '@@/Link';
|
|||
import { useSearchBarState } from '@@/datatables/SearchBar';
|
||||
|
||||
import { useList } from '../queries/list';
|
||||
import { EdgeUpdateSchedule } from '../types';
|
||||
import { EdgeUpdateSchedule, StatusType } from '../types';
|
||||
import { useRemoveMutation } from '../queries/useRemoveMutation';
|
||||
|
||||
import { columns } from './columns';
|
||||
|
@ -24,13 +21,13 @@ import { createStore } from './datatable-store';
|
|||
const storageKey = 'update-schedules-list';
|
||||
const settingsStore = createStore(storageKey);
|
||||
|
||||
export function ListView() {
|
||||
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
||||
export default withLimitToBE(ListView);
|
||||
|
||||
export function ListView() {
|
||||
const settings = useStore(settingsStore);
|
||||
const [search, setSearch] = useSearchBarState(storageKey);
|
||||
|
||||
const listQuery = useList();
|
||||
const listQuery = useList(true);
|
||||
|
||||
if (!listQuery.data) {
|
||||
return null;
|
||||
|
@ -61,6 +58,7 @@ export function ListView() {
|
|||
onSortByChange={settings.setSortBy}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
isRowSelectable={(row) => row.original.status === StatusType.Pending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -2,9 +2,9 @@ import { Column } from 'react-table';
|
|||
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../../types';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
|
||||
export const created: Column<EdgeUpdateSchedule> = {
|
||||
export const created: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Created',
|
||||
accessor: (row) => isoDateFromTimestamp(row.created),
|
||||
disableFilters: true,
|
||||
|
|
|
@ -4,11 +4,11 @@ import _ from 'lodash';
|
|||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../../types';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
|
||||
export const groups: Column<EdgeUpdateSchedule> = {
|
||||
export const groups: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Groups',
|
||||
accessor: 'groupIds',
|
||||
accessor: 'edgeGroupIds',
|
||||
Cell: GroupsCell,
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
|
@ -18,7 +18,7 @@ export const groups: Column<EdgeUpdateSchedule> = {
|
|||
|
||||
export function GroupsCell({
|
||||
value: groupsIds,
|
||||
}: CellProps<EdgeUpdateSchedule, Array<EdgeGroup['Id']>>) {
|
||||
}: CellProps<EdgeUpdateListItemResponse, Array<EdgeGroup['Id']>>) {
|
||||
const groupsQuery = useEdgeGroups();
|
||||
|
||||
const groups = _.compact(
|
||||
|
|
|
@ -2,9 +2,9 @@ import { CellProps, Column } from 'react-table';
|
|||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../../types';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
|
||||
export const name: Column<EdgeUpdateSchedule> = {
|
||||
export const name: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
id: 'name',
|
||||
|
@ -15,7 +15,10 @@ export const name: Column<EdgeUpdateSchedule> = {
|
|||
sortType: 'string',
|
||||
};
|
||||
|
||||
export function NameCell({ value: name, row }: CellProps<EdgeUpdateSchedule>) {
|
||||
export function NameCell({
|
||||
value: name,
|
||||
row,
|
||||
}: CellProps<EdgeUpdateListItemResponse>) {
|
||||
return (
|
||||
<Link to=".item" params={{ id: row.original.id }}>
|
||||
{name}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { EdgeUpdateSchedule, StatusType } from '../../types';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
import { StatusType } from '../../types';
|
||||
|
||||
export const scheduleStatus: Column<EdgeUpdateSchedule> = {
|
||||
export const scheduleStatus: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Status',
|
||||
accessor: (row) => row.status,
|
||||
disableFilters: true,
|
||||
|
@ -14,31 +15,17 @@ export const scheduleStatus: Column<EdgeUpdateSchedule> = {
|
|||
|
||||
function StatusCell({
|
||||
value: status,
|
||||
row: { original: schedule },
|
||||
}: CellProps<EdgeUpdateSchedule, EdgeUpdateSchedule['status']>) {
|
||||
if (schedule.time > Date.now() / 1000) {
|
||||
return 'Scheduled';
|
||||
row: {
|
||||
original: { statusMessage },
|
||||
},
|
||||
}: CellProps<
|
||||
EdgeUpdateListItemResponse,
|
||||
EdgeUpdateListItemResponse['status']
|
||||
>) {
|
||||
switch (status) {
|
||||
case StatusType.Failed:
|
||||
return statusMessage;
|
||||
default:
|
||||
return StatusType[status];
|
||||
}
|
||||
|
||||
const statusList = Object.entries(status).map(
|
||||
([environmentId, envStatus]) => ({ ...envStatus, environmentId })
|
||||
);
|
||||
|
||||
if (statusList.length === 0) {
|
||||
return 'No related environments';
|
||||
}
|
||||
|
||||
const error = statusList.find((s) => s.status === StatusType.Failed);
|
||||
|
||||
if (error) {
|
||||
return `Failed: (ID: ${error.environmentId}) ${error.error}`;
|
||||
}
|
||||
|
||||
const pending = statusList.find((s) => s.status === StatusType.Pending);
|
||||
|
||||
if (pending) {
|
||||
return 'Pending';
|
||||
}
|
||||
|
||||
return 'Success';
|
||||
}
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../../types';
|
||||
|
||||
export const scheduledTime: Column<EdgeUpdateSchedule> = {
|
||||
export const scheduledTime: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Scheduled Time & Date',
|
||||
accessor: (row) => isoDateFromTimestamp(row.time),
|
||||
accessor: (row) => row.scheduledTime,
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: false,
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { EdgeUpdateSchedule, ScheduleType } from '../../types';
|
||||
import { ScheduleType } from '../../types';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
|
||||
export const scheduleType: Column<EdgeUpdateSchedule> = {
|
||||
export const scheduleType: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Type',
|
||||
accessor: (row) => ScheduleType[row.type],
|
||||
disableFilters: true,
|
||||
|
|
|
@ -1 +1 @@
|
|||
export { ListView } from './ListView';
|
||||
export { default as ListView } from './ListView';
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { InformationPanel } from '@@/InformationPanel';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
export function BetaAlert() {
|
||||
return (
|
||||
<InformationPanel title="Limited Feature">
|
||||
<TextTip>
|
||||
This feature is currently in beta and is limited to standalone linux
|
||||
edge devices.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { useField } from 'formik';
|
||||
import { FormikErrors, FormikHandlers } from 'formik';
|
||||
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
|
||||
|
@ -9,14 +9,21 @@ import { FormValues } from './types';
|
|||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
onBlur: FormikHandlers['handleBlur'];
|
||||
value: FormValues['groupIds'];
|
||||
error?: FormikErrors<FormValues>['groupIds'];
|
||||
onChange(value: FormValues['groupIds']): void;
|
||||
}
|
||||
|
||||
export function EdgeGroupsField({ disabled }: Props) {
|
||||
export function EdgeGroupsField({
|
||||
disabled,
|
||||
onBlur,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const groupsQuery = useEdgeGroups();
|
||||
|
||||
const [{ name, onBlur, value }, { error }, { setValue }] =
|
||||
useField<FormValues['groupIds']>('groupIds');
|
||||
|
||||
const selectedGroups = groupsQuery.data?.filter((group) =>
|
||||
value.includes(group.Id)
|
||||
);
|
||||
|
@ -24,12 +31,12 @@ export function EdgeGroupsField({ disabled }: Props) {
|
|||
return (
|
||||
<FormControl label="Groups" required inputId="groups-select" errors={error}>
|
||||
<Select
|
||||
name={name}
|
||||
name="groupIds"
|
||||
onBlur={onBlur}
|
||||
value={selectedGroups}
|
||||
inputId="groups-select"
|
||||
placeholder="Select one or multiple group(s)"
|
||||
onChange={(selectedGroups) => setValue(selectedGroups.map((g) => g.Id))}
|
||||
onChange={(selectedGroups) => onChange(selectedGroups.map((g) => g.Id))}
|
||||
isMulti
|
||||
options={groupsQuery.data || []}
|
||||
getOptionLabel={(group) => group.Name}
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { ActiveSchedule } from '../queries/useActiveSchedules';
|
||||
import { useSupportedAgentVersions } from '../queries/useSupportedAgentVersions';
|
||||
|
||||
import { EnvironmentSelectionItem } from './EnvironmentSelectionItem';
|
||||
import { compareVersion } from './utils';
|
||||
|
||||
interface Props {
|
||||
environments: Environment[];
|
||||
activeSchedules: ActiveSchedule[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function EnvironmentSelection({
|
||||
environments,
|
||||
activeSchedules,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const supportedAgentVersionsQuery = useSupportedAgentVersions({
|
||||
select: (versions) =>
|
||||
versions.map((version) => ({ label: version, value: version })),
|
||||
});
|
||||
|
||||
if (!supportedAgentVersionsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supportedAgentVersions = supportedAgentVersionsQuery.data;
|
||||
|
||||
const latestVersion = _.last(supportedAgentVersions)?.value;
|
||||
|
||||
const environmentsToUpdate = environments.filter(
|
||||
(env) =>
|
||||
activeSchedules.every((schedule) => schedule.environmentId !== env.Id) &&
|
||||
compareVersion(env.Agent.Version, latestVersion)
|
||||
);
|
||||
|
||||
const versionGroups = Object.entries(
|
||||
_.mapValues(
|
||||
_.groupBy(environmentsToUpdate, (env) => env.Agent.Version),
|
||||
(envs) => envs.map((env) => env.Id)
|
||||
)
|
||||
);
|
||||
|
||||
if (environmentsToUpdate.length === 0) {
|
||||
return (
|
||||
<TextTip>
|
||||
The are no update options available for yor selected groups(s)
|
||||
</TextTip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
{versionGroups.map(([version, environmentIds]) => (
|
||||
<EnvironmentSelectionItem
|
||||
currentVersion={version}
|
||||
environmentIds={environmentIds}
|
||||
key={version}
|
||||
versions={supportedAgentVersions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,85 +0,0 @@
|
|||
import { useField } from 'formik';
|
||||
import _ from 'lodash';
|
||||
import { useState, ChangeEvent } from 'react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Select } from '@@/form-components/Input';
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { compareVersion } from './utils';
|
||||
|
||||
interface Props {
|
||||
currentVersion: string;
|
||||
environmentIds: EnvironmentId[];
|
||||
versions: { label: string; value: string }[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function EnvironmentSelectionItem({
|
||||
environmentIds,
|
||||
versions,
|
||||
currentVersion = 'unknown',
|
||||
disabled,
|
||||
}: Props) {
|
||||
const [{ value }, , { setValue }] =
|
||||
useField<FormValues['environments']>('environments');
|
||||
const isChecked = environmentIds.every((envId) => !!value[envId]);
|
||||
const supportedVersions = versions.filter(
|
||||
({ value }) => compareVersion(currentVersion, value) // versions that are bigger than the current version
|
||||
);
|
||||
|
||||
const maxVersion = _.last(supportedVersions)?.value;
|
||||
|
||||
const [selectedVersion, setSelectedVersion] = useState(
|
||||
value[environmentIds[0]] || maxVersion || ''
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
className="flex items-center"
|
||||
id={`version_checkbox_${currentVersion}`}
|
||||
checked={isChecked}
|
||||
onChange={() => handleChange(!isChecked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<span className="font-normal flex items-center whitespace-nowrap gap-1">
|
||||
{environmentIds.length} edge agents update from v{currentVersion} to
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={selectedVersion}
|
||||
options={supportedVersions}
|
||||
onChange={handleVersionChange}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleVersionChange(e: ChangeEvent<HTMLSelectElement>) {
|
||||
const version = e.target.value;
|
||||
setSelectedVersion(version);
|
||||
if (isChecked) {
|
||||
handleChange(isChecked, version);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(isChecked: boolean, version: string = selectedVersion) {
|
||||
const newValue = !isChecked
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).filter(
|
||||
([envId]) => !environmentIds.includes(parseInt(envId, 10))
|
||||
)
|
||||
)
|
||||
: {
|
||||
...value,
|
||||
...Object.fromEntries(
|
||||
environmentIds.map((envId) => [envId, version])
|
||||
),
|
||||
};
|
||||
|
||||
setValue(newValue);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
import _ from 'lodash';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { usePreviousVersions } from '../queries/usePreviousVersions';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
|
||||
|
||||
export function RollbackOptions() {
|
||||
const { isLoading, count, version, versionError } = useSelectVersionOnMount();
|
||||
|
||||
const groupNames = useGroupNames();
|
||||
|
||||
if (versionError) {
|
||||
return <TextTip>{versionError}</TextTip>;
|
||||
}
|
||||
|
||||
if (!count) {
|
||||
return (
|
||||
<TextTip>
|
||||
The are no rollback options available for yor selected groups(s)
|
||||
</TextTip>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !groupNames) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
{count} edge device(s) from {groupNames} will rollback to version{' '}
|
||||
{version}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSelectVersionOnMount() {
|
||||
const {
|
||||
values: { groupIds, version },
|
||||
setFieldValue,
|
||||
setFieldError,
|
||||
errors: { version: versionError },
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
const environmentIdsQuery = useEdgeGroupsEnvironmentIds(groupIds);
|
||||
|
||||
const previousVersionsQuery = usePreviousVersions<string[]>({
|
||||
enabled: !!environmentIdsQuery.data,
|
||||
});
|
||||
|
||||
const previousVersions = useMemo(
|
||||
() =>
|
||||
previousVersionsQuery.data
|
||||
? _.uniq(
|
||||
_.compact(
|
||||
environmentIdsQuery.data?.map(
|
||||
(envId) => previousVersionsQuery.data[envId]
|
||||
)
|
||||
)
|
||||
)
|
||||
: [],
|
||||
[environmentIdsQuery.data, previousVersionsQuery.data]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
switch (previousVersions.length) {
|
||||
case 0:
|
||||
setFieldError('version', 'No rollback options available');
|
||||
break;
|
||||
case 1:
|
||||
setFieldValue('version', previousVersions[0]);
|
||||
break;
|
||||
default:
|
||||
setFieldError(
|
||||
'version',
|
||||
'Rollback is not available for these edge group as there are multiple version types to rollback to'
|
||||
);
|
||||
}
|
||||
}, [previousVersions, setFieldError, setFieldValue]);
|
||||
|
||||
return {
|
||||
isLoading: previousVersionsQuery.isLoading,
|
||||
versionError,
|
||||
version,
|
||||
count: environmentIdsQuery.data?.length,
|
||||
};
|
||||
}
|
||||
|
||||
function useGroupNames() {
|
||||
const {
|
||||
values: { groupIds },
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
const groupsQuery = useEdgeGroups({
|
||||
select: (groups) => Object.fromEntries(groups.map((g) => [g.Id, g.Name])),
|
||||
});
|
||||
|
||||
if (!groupsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return groupIds.map((id) => groupsQuery.data[id]).join(', ');
|
||||
}
|
|
@ -1,95 +1,11 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { usePreviousVersions } from '../queries/usePreviousVersions';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
|
||||
import { RollbackOptions } from './RollbackOptions';
|
||||
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||
|
||||
export function RollbackScheduleDetailsFieldset() {
|
||||
const environmentsCount = useSelectedEnvironmentsCount();
|
||||
const { isLoading } = useSelectEnvironmentsOnMount();
|
||||
|
||||
const groupNames = useGroupNames();
|
||||
|
||||
if (isLoading || !groupNames) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
{environmentsCount > 0 ? (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
{environmentsCount} edge device(s) from {groupNames} will rollback
|
||||
to their previous versions
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TextTip>
|
||||
The are no rollback options available for yor selected groups(s)
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
<RollbackOptions />
|
||||
<ScheduledTimeField />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSelectedEnvironmentsCount() {
|
||||
const {
|
||||
values: { environments },
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
return Object.keys(environments).length;
|
||||
}
|
||||
|
||||
function useSelectEnvironmentsOnMount() {
|
||||
const previousVersionsQuery = usePreviousVersions();
|
||||
|
||||
const {
|
||||
values: { groupIds },
|
||||
setFieldValue,
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
const edgeGroupsEnvironmentIds = useEdgeGroupsEnvironmentIds(groupIds);
|
||||
|
||||
const envIdsToUpdate = useMemo(
|
||||
() =>
|
||||
previousVersionsQuery.data
|
||||
? Object.fromEntries(
|
||||
edgeGroupsEnvironmentIds
|
||||
.map((id) => [id, previousVersionsQuery.data[id] || ''] as const)
|
||||
.filter(([, version]) => !!version)
|
||||
)
|
||||
: [],
|
||||
[edgeGroupsEnvironmentIds, previousVersionsQuery.data]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFieldValue('environments', envIdsToUpdate);
|
||||
}, [envIdsToUpdate, setFieldValue]);
|
||||
|
||||
return { isLoading: previousVersionsQuery.isLoading };
|
||||
}
|
||||
|
||||
function useGroupNames() {
|
||||
const {
|
||||
values: { groupIds },
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
const groupsQuery = useEdgeGroups({
|
||||
select: (groups) => Object.fromEntries(groups.map((g) => [g.Id, g.Name])),
|
||||
});
|
||||
|
||||
if (!groupsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return groupIds.map((id) => groupsQuery.data[id]).join(', ');
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useField } from 'formik';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { number } from 'yup';
|
||||
|
||||
import { NavTabs } from '@@/NavTabs';
|
||||
|
@ -10,7 +10,7 @@ import { UpdateScheduleDetailsFieldset } from './UpdateScheduleDetailsFieldset';
|
|||
import { RollbackScheduleDetailsFieldset } from './RollbackScheduleDetailsFieldset';
|
||||
|
||||
export function ScheduleTypeSelector() {
|
||||
const [{ value }, , { setValue }] = useField<FormValues['type']>('type');
|
||||
const { values, setFieldValue } = useFormikContext<FormValues>();
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
|
@ -28,12 +28,17 @@ export function ScheduleTypeSelector() {
|
|||
children: <RollbackScheduleDetailsFieldset />,
|
||||
},
|
||||
]}
|
||||
selectedId={value}
|
||||
onSelect={(value) => setValue(value)}
|
||||
selectedId={values.type}
|
||||
onSelect={handleChangeType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChangeType(scheduleType: ScheduleType) {
|
||||
setFieldValue('type', scheduleType);
|
||||
setFieldValue('version', '');
|
||||
}
|
||||
}
|
||||
|
||||
export function typeValidation() {
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
import { useField } from 'formik';
|
||||
import DateTimePicker from 'react-datetime-picker';
|
||||
import { Calendar, X } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { string } from 'yup';
|
||||
import { useField } from 'formik';
|
||||
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
import {
|
||||
isoDate,
|
||||
parseIsoDate,
|
||||
TIME_FORMAT,
|
||||
} from '@/portainer/filters/filters';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
@ -16,27 +21,49 @@ interface Props {
|
|||
|
||||
export function ScheduledTimeField({ disabled }: Props) {
|
||||
const [{ name, value }, { error }, { setValue }] =
|
||||
useField<FormValues['time']>('time');
|
||||
useField<FormValues['scheduledTime']>('scheduledTime');
|
||||
|
||||
const dateValue = useMemo(() => new Date(value * 1000), [value]);
|
||||
const dateValue = useMemo(() => parseIsoDate(value), [value]);
|
||||
|
||||
return (
|
||||
<FormControl label="Schedule date & time" errors={error}>
|
||||
{!disabled ? (
|
||||
<DateTimePicker
|
||||
format="y-MM-dd HH:mm:ss"
|
||||
minDate={new Date()}
|
||||
className="form-control [&>div]:border-0"
|
||||
onChange={(date) => setValue(Math.floor(date.getTime() / 1000))}
|
||||
onChange={(date) => setValue(isoDate(date.valueOf()))}
|
||||
name={name}
|
||||
value={dateValue}
|
||||
calendarIcon={<Calendar className="lucide" />}
|
||||
clearIcon={<X className="lucide" />}
|
||||
disableClock
|
||||
minDate={new Date(Date.now() - 24 * 60 * 60 * 1000)}
|
||||
/>
|
||||
) : (
|
||||
<Input defaultValue={isoDateFromTimestamp(value)} disabled />
|
||||
<Input defaultValue={value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
export function timeValidation() {
|
||||
return string()
|
||||
.required('Scheduled time is required')
|
||||
.test(
|
||||
'validFormat',
|
||||
`Scheduled time must be in the format ${TIME_FORMAT}`,
|
||||
(value) => isValidDate(parseIsoDate(value))
|
||||
)
|
||||
.test(
|
||||
'validDate',
|
||||
`Scheduled time must be bigger then ${isoDate(
|
||||
Date.now() - 24 * 60 * 60 * 1000
|
||||
)}`,
|
||||
(value) =>
|
||||
parseIsoDate(value).valueOf() > Date.now() - 24 * 60 * 60 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
function isValidDate(date: Date) {
|
||||
return date instanceof Date && !Number.isNaN(date.valueOf());
|
||||
}
|
||||
|
|
|
@ -1,39 +1,58 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import semverCompare from 'semver-compare';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
|
||||
import { useActiveSchedules } from '../queries/useActiveSchedules';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||
import { FormValues } from './types';
|
||||
import { EnvironmentSelection } from './EnvironmentSelection';
|
||||
import { ActiveSchedulesNotice } from './ActiveSchedulesNotice';
|
||||
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
|
||||
import { VersionSelect } from './VersionSelect';
|
||||
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||
|
||||
export function UpdateScheduleDetailsFieldset() {
|
||||
const { values } = useFormikContext<FormValues>();
|
||||
|
||||
const edgeGroupsEnvironmentIds = useEdgeGroupsEnvironmentIds(values.groupIds);
|
||||
const environmentIdsQuery = useEdgeGroupsEnvironmentIds(values.groupIds);
|
||||
|
||||
const edgeGroupsEnvironmentIds = environmentIdsQuery.data || [];
|
||||
const environments = useEnvironments(edgeGroupsEnvironmentIds);
|
||||
const activeSchedules = useRelevantActiveSchedules(edgeGroupsEnvironmentIds);
|
||||
const minVersion = _.first(
|
||||
_.compact<string>(environments.map((env) => env.Agent.Version)).sort(
|
||||
(a, b) => semverCompare(a, b)
|
||||
)
|
||||
);
|
||||
|
||||
// old version is version that doesn't support scheduling of updates
|
||||
const hasNoTimeZone = environments.some((env) => !env.LocalTimeZone);
|
||||
const hasTimeZone = environments.some((env) => env.LocalTimeZone);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActiveSchedulesNotice
|
||||
selectedEdgeGroupIds={values.groupIds}
|
||||
activeSchedules={activeSchedules}
|
||||
environments={environments}
|
||||
/>
|
||||
{edgeGroupsEnvironmentIds.length > 0 ? (
|
||||
!!values.version && (
|
||||
<TextTip color="blue">
|
||||
{edgeGroupsEnvironmentIds.length} environment(s) will be updated to{' '}
|
||||
{values.version}
|
||||
</TextTip>
|
||||
)
|
||||
) : (
|
||||
<TextTip color="orange">
|
||||
No environments options for the selected edge groups
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
<EnvironmentSelection
|
||||
activeSchedules={activeSchedules}
|
||||
environments={environments}
|
||||
/>
|
||||
<VersionSelect minVersion={minVersion} />
|
||||
|
||||
<ScheduledTimeField />
|
||||
{hasTimeZone && <ScheduledTimeField />}
|
||||
{hasNoTimeZone && (
|
||||
<TextTip>
|
||||
These edge groups have older versions of the edge agent that do not
|
||||
support scheduling, these will happen immediately
|
||||
</TextTip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -48,17 +67,3 @@ function useEnvironments(environmentsIds: Array<EnvironmentId>) {
|
|||
|
||||
return environmentsQuery.environments;
|
||||
}
|
||||
|
||||
function useRelevantActiveSchedules(environmentIds: EnvironmentId[]) {
|
||||
const { params } = useCurrentStateAndParams();
|
||||
|
||||
const scheduleId = params.id ? parseInt(params.id, 10) : 0;
|
||||
|
||||
const activeSchedulesQuery = useActiveSchedules(environmentIds);
|
||||
|
||||
return (
|
||||
activeSchedulesQuery.data?.filter(
|
||||
(schedule) => schedule.scheduleId !== scheduleId
|
||||
) || []
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import { Field, useField } from 'formik';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Select } from '@@/form-components/Input';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { useSupportedAgentVersions } from '../queries/useSupportedAgentVersions';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
/**
|
||||
* in-case agents don't have any version field, it means they are version less then 2.15.x or that they still not associated.
|
||||
*/
|
||||
const DEFAULT_MIN_VERSION = '2.14.10';
|
||||
|
||||
export function VersionSelect({
|
||||
minVersion = DEFAULT_MIN_VERSION,
|
||||
}: {
|
||||
minVersion?: string;
|
||||
}) {
|
||||
const [{ value: version }, { error }, { setValue }] =
|
||||
useField<FormValues['version']>('version');
|
||||
const supportedAgentVersionsQuery = useSupportedAgentVersions(minVersion, {
|
||||
onSuccess(versions) {
|
||||
if (versions.includes(version)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(_.last(versions) || '');
|
||||
},
|
||||
});
|
||||
|
||||
if (!supportedAgentVersionsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!supportedAgentVersionsQuery.data.length) {
|
||||
return (
|
||||
<FormControl label="Version">
|
||||
<TextTip>No supported versions available</TextTip>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
const supportedVersions = supportedAgentVersionsQuery.data.map((version) => ({
|
||||
label: version,
|
||||
value: version,
|
||||
}));
|
||||
|
||||
return (
|
||||
<FormControl label="Version" errors={error} inputId="version-input">
|
||||
<Field
|
||||
id="version-input"
|
||||
name="version"
|
||||
as={Select}
|
||||
className="form-control"
|
||||
placeholder="Version"
|
||||
options={supportedVersions}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
|
||||
import { ScheduleType } from '../types';
|
||||
|
@ -7,6 +6,6 @@ export interface FormValues {
|
|||
name: string;
|
||||
groupIds: EdgeGroup['Id'][];
|
||||
type: ScheduleType;
|
||||
time: number;
|
||||
environments: Record<EnvironmentId, string>;
|
||||
version: string;
|
||||
scheduledTime: string;
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ export function useEdgeGroupsEnvironmentIds(
|
|||
Object.fromEntries(groups.map((g) => [g.Id, g.Endpoints])),
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
const envIds = useMemo(
|
||||
() =>
|
||||
_.uniq(
|
||||
_.compact(
|
||||
|
@ -23,4 +23,12 @@ export function useEdgeGroupsEnvironmentIds(
|
|||
),
|
||||
[edgeGroupsIds, groupsQuery.data]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
data: groupsQuery.data ? envIds : null,
|
||||
isLoading: groupsQuery.isLoading,
|
||||
}),
|
||||
[envIds, groupsQuery.data, groupsQuery.isLoading]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { array, number, object } from 'yup';
|
||||
import { array, object, string } from 'yup';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../types';
|
||||
import { EdgeUpdateSchedule, ScheduleType } from '../types';
|
||||
|
||||
import { nameValidation } from './NameField';
|
||||
import { typeValidation } from './ScheduleTypeSelector';
|
||||
|
@ -13,9 +13,15 @@ export function validation(
|
|||
groupIds: array().min(1, 'At least one group is required'),
|
||||
name: nameValidation(schedules, currentId),
|
||||
type: typeValidation(),
|
||||
time: number()
|
||||
.min(Date.now() / 1000)
|
||||
.required(),
|
||||
environments: object().default({}),
|
||||
// time: number()
|
||||
// .min(Date.now() / 1000)
|
||||
// .required(),
|
||||
version: string().when('type', {
|
||||
is: ScheduleType.Update,
|
||||
// update type
|
||||
then: (schema) => schema.required('Version is required'),
|
||||
// rollback
|
||||
otherwise: (schema) => schema.required('No rollback options available'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ async function create(schedule: FormValues) {
|
|||
export function useCreateMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(create, {
|
||||
...withInvalidate(queryClient, [queryKeys.list()]),
|
||||
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||
...withError(),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2,14 +2,21 @@ import { useQuery } from 'react-query';
|
|||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../types';
|
||||
import { EdgeUpdateResponse, StatusType } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './urls';
|
||||
|
||||
async function getList() {
|
||||
export type EdgeUpdateListItemResponse = EdgeUpdateResponse & {
|
||||
status: StatusType;
|
||||
statusMessage: string;
|
||||
};
|
||||
|
||||
async function getList(includeEdgeStacks?: boolean) {
|
||||
try {
|
||||
const { data } = await axios.get<EdgeUpdateSchedule[]>(buildUrl());
|
||||
const { data } = await axios.get<EdgeUpdateListItemResponse[]>(buildUrl(), {
|
||||
params: { includeEdgeStacks },
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(
|
||||
|
@ -19,6 +26,8 @@ async function getList() {
|
|||
}
|
||||
}
|
||||
|
||||
export function useList() {
|
||||
return useQuery(queryKeys.list(), getList);
|
||||
export function useList(includeEdgeStacks?: boolean) {
|
||||
return useQuery(queryKeys.list(includeEdgeStacks), () =>
|
||||
getList(includeEdgeStacks)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,10 +3,13 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
|||
import { EdgeUpdateSchedule } from '../types';
|
||||
|
||||
export const queryKeys = {
|
||||
list: () => ['edge', 'update_schedules'] as const,
|
||||
item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.list(), id] as const,
|
||||
base: () => ['edge', 'update_schedules'] as const,
|
||||
list: (includeEdgeStacks?: boolean) =>
|
||||
[...queryKeys.base(), { includeEdgeStacks }] as const,
|
||||
item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.base(), id] as const,
|
||||
activeSchedules: (environmentIds: EnvironmentId[]) =>
|
||||
[queryKeys.list(), 'active', { environmentIds }] as const,
|
||||
supportedAgentVersions: () => [queryKeys.list(), 'agent_versions'] as const,
|
||||
previousVersions: () => [queryKeys.list(), 'previous_versions'] as const,
|
||||
[...queryKeys.base(), 'active', { environmentIds }] as const,
|
||||
supportedAgentVersions: () =>
|
||||
[...queryKeys.base(), 'agent_versions'] as const,
|
||||
previousVersions: () => [...queryKeys.base(), 'previous_versions'] as const,
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useQuery } from 'react-query';
|
|||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../types';
|
||||
import { EdgeUpdateResponse, EdgeUpdateSchedule } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './urls';
|
||||
|
@ -11,9 +11,15 @@ export function useItem(id: EdgeUpdateSchedule['id']) {
|
|||
return useQuery(queryKeys.item(id), () => getItem(id));
|
||||
}
|
||||
|
||||
type EdgeUpdateItemResponse = EdgeUpdateResponse & {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
async function getItem(id: EdgeUpdateSchedule['id']) {
|
||||
try {
|
||||
const { data } = await axios.get<EdgeUpdateSchedule>(buildUrl(id));
|
||||
const { data } = await axios.get<EdgeUpdateItemResponse>(buildUrl(id), {
|
||||
params: { includeEdgeStack: true },
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(
|
||||
|
|
|
@ -6,11 +6,21 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
|||
import { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './urls';
|
||||
|
||||
interface Options<T> {
|
||||
select?: (data: Record<EnvironmentId, string>) => T;
|
||||
onSuccess?(data: T): void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function usePreviousVersions<T = Record<EnvironmentId, string>>({
|
||||
select,
|
||||
}: { select?: (data: Record<EnvironmentId, string>) => T } = {}) {
|
||||
onSuccess,
|
||||
enabled,
|
||||
}: Options<T> = {}) {
|
||||
return useQuery(queryKeys.previousVersions(), getPreviousVersions, {
|
||||
select,
|
||||
onSuccess,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ export function useRemoveMutation() {
|
|||
),
|
||||
|
||||
mutationOptions(
|
||||
withInvalidate(queryClient, [queryKeys.list()]),
|
||||
withInvalidate(queryClient, [queryKeys.base()]),
|
||||
withError()
|
||||
)
|
||||
);
|
||||
|
|
|
@ -1,17 +1,32 @@
|
|||
import { useQuery } from 'react-query';
|
||||
import semverCompare from 'semver-compare';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './urls';
|
||||
|
||||
export function useSupportedAgentVersions<T = string[]>({
|
||||
select,
|
||||
}: { select?: (data: string[]) => T } = {}) {
|
||||
export function useSupportedAgentVersions(
|
||||
minVersion?: string,
|
||||
{ onSuccess }: { onSuccess?(data: string[]): void } = {}
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.supportedAgentVersions(),
|
||||
[...queryKeys.supportedAgentVersions(), { minVersion }],
|
||||
getSupportedAgentVersions,
|
||||
{ select }
|
||||
{
|
||||
select(versions) {
|
||||
if (!minVersion) {
|
||||
return versions;
|
||||
}
|
||||
|
||||
return versions.filter(
|
||||
(version) => semverCompare(version, minVersion) > 0
|
||||
);
|
||||
},
|
||||
onSuccess,
|
||||
...withError('failed fetching available agent versions'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import { buildUrl } from './urls';
|
|||
|
||||
interface Update {
|
||||
id: EdgeUpdateSchedule['id'];
|
||||
values: FormValues;
|
||||
values: Partial<FormValues>;
|
||||
}
|
||||
|
||||
async function update({ id, values }: Update) {
|
||||
|
@ -30,7 +30,7 @@ async function update({ id, values }: Update) {
|
|||
export function useUpdateMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(update, {
|
||||
...withInvalidate(queryClient, [queryKeys.list()]),
|
||||
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||
...withError(),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,22 +11,23 @@ export enum StatusType {
|
|||
Pending,
|
||||
Failed,
|
||||
Success,
|
||||
}
|
||||
|
||||
interface Status {
|
||||
status: StatusType;
|
||||
error: string;
|
||||
targetVersion: string;
|
||||
currentVersion: string;
|
||||
Sent,
|
||||
}
|
||||
|
||||
export type EdgeUpdateSchedule = {
|
||||
id: number;
|
||||
name: string;
|
||||
time: number;
|
||||
groupIds: EdgeGroup['Id'][];
|
||||
|
||||
type: ScheduleType;
|
||||
status: { [key: EnvironmentId]: Status };
|
||||
|
||||
created: number;
|
||||
createdBy: UserId;
|
||||
version: string;
|
||||
environmentsPreviousVersions: Record<EnvironmentId, string>;
|
||||
};
|
||||
|
||||
export type EdgeUpdateResponse = EdgeUpdateSchedule & {
|
||||
// from edge stack:
|
||||
edgeGroupIds: EdgeGroup['Id'][];
|
||||
scheduledTime: string;
|
||||
};
|
||||
|
|
|
@ -3,7 +3,6 @@ import { useRouter } from '@uirouter/react';
|
|||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||
|
||||
export enum FeatureFlag {
|
||||
EdgeRemoteUpdate = 'edgeRemoteUpdate',
|
||||
BEUpgrade = 'beUpgrade',
|
||||
}
|
||||
|
||||
|
|
|
@ -9,10 +9,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
|
||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||
import {
|
||||
FeatureFlag,
|
||||
useFeatureFlag,
|
||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { SidebarItem } from './SidebarItem';
|
||||
import { SidebarSection } from './SidebarSection';
|
||||
|
@ -27,10 +24,6 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
|||
select: (settings) => settings.TeamSync,
|
||||
});
|
||||
|
||||
const isEdgeRemoteUpgradeEnabledQuery = useFeatureFlag(
|
||||
FeatureFlag.EdgeRemoteUpdate
|
||||
);
|
||||
|
||||
const showUsersSection =
|
||||
!window.ddExtension && (isAdmin || (isTeamLeader && !teamSyncQuery.data));
|
||||
|
||||
|
@ -77,7 +70,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
|||
label="Tags"
|
||||
data-cy="portainerSidebar-environmentTags"
|
||||
/>
|
||||
{isEdgeRemoteUpgradeEnabledQuery.data && (
|
||||
{isBE && (
|
||||
<SidebarItem
|
||||
to="portainer.endpoints.updateSchedules"
|
||||
label="Update & Rollback"
|
||||
|
@ -93,7 +86,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
|||
data-cy="portainerSidebar-registries"
|
||||
/>
|
||||
|
||||
{process.env.PORTAINER_EDITION !== 'CE' && (
|
||||
{isBE && (
|
||||
<SidebarItem
|
||||
to="portainer.licenses"
|
||||
label="Licenses"
|
||||
|
@ -136,7 +129,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
|||
data-cy="portainerSidebar-authentication"
|
||||
/>
|
||||
)}
|
||||
{process.env.PORTAINER_EDITION !== 'CE' && (
|
||||
{isBE && (
|
||||
<SidebarItem
|
||||
to="portainer.settings.cloud"
|
||||
label="Cloud"
|
||||
|
|
Loading…
Reference in New Issue