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"
|
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
"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/snapshot"
|
||||||
"github.com/portainer/portainer/api/internal/ssl"
|
"github.com/portainer/portainer/api/internal/ssl"
|
||||||
"github.com/portainer/portainer/api/jwt"
|
"github.com/portainer/portainer/api/jwt"
|
||||||
|
@ -587,6 +588,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
|
|
||||||
digitalSignatureService := initDigitalSignatureService()
|
digitalSignatureService := initDigitalSignatureService()
|
||||||
|
|
||||||
|
edgeStacksService := edgestacks.NewService(dataStore)
|
||||||
|
|
||||||
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger)
|
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal().Err(err).Msg("")
|
log.Fatal().Err(err).Msg("")
|
||||||
|
@ -738,6 +741,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||||
HTTPEnabled: sslDBSettings.HTTPEnabled,
|
HTTPEnabled: sslDBSettings.HTTPEnabled,
|
||||||
AssetsPath: *flags.Assets,
|
AssetsPath: *flags.Assets,
|
||||||
DataStore: dataStore,
|
DataStore: dataStore,
|
||||||
|
EdgeStacksService: edgeStacksService,
|
||||||
SwarmStackManager: swarmStackManager,
|
SwarmStackManager: swarmStackManager,
|
||||||
ComposeStackManager: composeStackManager,
|
ComposeStackManager: composeStackManager,
|
||||||
KubernetesDeployer: kubernetesDeployer,
|
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/database/models"
|
||||||
"github.com/portainer/portainer/api/dataservices/errors"
|
"github.com/portainer/portainer/api/dataservices/errors"
|
||||||
"github.com/portainer/portainer/api/edgetypes"
|
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
)
|
)
|
||||||
|
@ -29,7 +28,6 @@ type (
|
||||||
EdgeGroup() EdgeGroupService
|
EdgeGroup() EdgeGroupService
|
||||||
EdgeJob() EdgeJobService
|
EdgeJob() EdgeJobService
|
||||||
EdgeStack() EdgeStackService
|
EdgeStack() EdgeStackService
|
||||||
EdgeUpdateSchedule() EdgeUpdateScheduleService
|
|
||||||
Endpoint() EndpointService
|
Endpoint() EndpointService
|
||||||
EndpointGroup() EndpointGroupService
|
EndpointGroup() EndpointGroupService
|
||||||
EndpointRelation() EndpointRelationService
|
EndpointRelation() EndpointRelationService
|
||||||
|
@ -85,17 +83,6 @@ type (
|
||||||
BucketName() string
|
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 represents a service to manage Edge stacks
|
||||||
EdgeStackService interface {
|
EdgeStackService interface {
|
||||||
EdgeStacks() ([]portainer.EdgeStack, error)
|
EdgeStacks() ([]portainer.EdgeStack, error)
|
||||||
|
|
|
@ -14,7 +14,6 @@ import (
|
||||||
"github.com/portainer/portainer/api/dataservices/edgegroup"
|
"github.com/portainer/portainer/api/dataservices/edgegroup"
|
||||||
"github.com/portainer/portainer/api/dataservices/edgejob"
|
"github.com/portainer/portainer/api/dataservices/edgejob"
|
||||||
"github.com/portainer/portainer/api/dataservices/edgestack"
|
"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/endpoint"
|
||||||
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
"github.com/portainer/portainer/api/dataservices/endpointgroup"
|
||||||
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
||||||
|
@ -50,7 +49,6 @@ type Store struct {
|
||||||
DockerHubService *dockerhub.Service
|
DockerHubService *dockerhub.Service
|
||||||
EdgeGroupService *edgegroup.Service
|
EdgeGroupService *edgegroup.Service
|
||||||
EdgeJobService *edgejob.Service
|
EdgeJobService *edgejob.Service
|
||||||
EdgeUpdateScheduleService *edgeupdateschedule.Service
|
|
||||||
EdgeStackService *edgestack.Service
|
EdgeStackService *edgestack.Service
|
||||||
EndpointGroupService *endpointgroup.Service
|
EndpointGroupService *endpointgroup.Service
|
||||||
EndpointService *endpoint.Service
|
EndpointService *endpoint.Service
|
||||||
|
@ -95,12 +93,6 @@ func (store *Store) initServices() error {
|
||||||
}
|
}
|
||||||
store.DockerHubService = dockerhubService
|
store.DockerHubService = dockerhubService
|
||||||
|
|
||||||
edgeUpdateScheduleService, err := edgeupdateschedule.NewService(store.connection)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
store.EdgeUpdateScheduleService = edgeUpdateScheduleService
|
|
||||||
|
|
||||||
edgeStackService, err := edgestack.NewService(store.connection)
|
edgeStackService, err := edgestack.NewService(store.connection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -263,11 +255,6 @@ func (store *Store) EdgeJob() dataservices.EdgeJobService {
|
||||||
return store.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
|
// EdgeStack gives access to the EdgeStack data management layer
|
||||||
func (store *Store) EdgeStack() dataservices.EdgeStackService {
|
func (store *Store) EdgeStack() dataservices.EdgeStackService {
|
||||||
return store.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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"github.com/asaskevich/govalidator"
|
||||||
|
"github.com/pkg/errors"
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
"github.com/portainer/libhttp/request"
|
"github.com/portainer/libhttp/request"
|
||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
gittypes "github.com/portainer/portainer/api/git/types"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/asaskevich/govalidator"
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type InvalidPayloadError struct {
|
type InvalidPayloadError struct {
|
||||||
|
@ -47,8 +43,14 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.BadRequest("Invalid query parameter: method", err)
|
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 {
|
if err != nil {
|
||||||
var payloadError *InvalidPayloadError
|
var payloadError *InvalidPayloadError
|
||||||
switch {
|
switch {
|
||||||
|
@ -62,14 +64,15 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
||||||
return response.JSON(w, edgeStack)
|
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 {
|
switch method {
|
||||||
case "string":
|
case "string":
|
||||||
return handler.createSwarmStackFromFileContent(r)
|
return handler.createSwarmStackFromFileContent(r, dryrun)
|
||||||
case "repository":
|
case "repository":
|
||||||
return handler.createSwarmStackFromGitRepository(r)
|
return handler.createSwarmStackFromGitRepository(r, dryrun, userID)
|
||||||
case "file":
|
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")
|
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
|
// List of identifiers of EdgeGroups
|
||||||
EdgeGroups []portainer.EdgeGroupID `example:"1"`
|
EdgeGroups []portainer.EdgeGroupID `example:"1"`
|
||||||
// Deployment type to deploy this stack
|
// 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)
|
// for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints)
|
||||||
// kubernetes deploytype is enabled only for kubernetes environments(endpoints)
|
// kubernetes deploy type is enabled only for kubernetes environments(endpoints)
|
||||||
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"`
|
// 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 {
|
func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error {
|
||||||
|
@ -101,85 +107,69 @@ func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error
|
||||||
return nil
|
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
|
var payload swarmStackFromFileContentPayload
|
||||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.validateUniqueName(payload.Name)
|
stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, errors.Wrap(err, "failed to create Edge stack object")
|
||||||
}
|
}
|
||||||
|
|
||||||
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
if dryrun {
|
||||||
stack := &portainer.EdgeStack{
|
return stack, nil
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||||
if err != nil {
|
return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, []byte(payload.StackFileContent))
|
||||||
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))
|
func (handler *Handler) storeFileContent(stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) {
|
||||||
if stack.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
if deploymentType == portainer.EdgeStackDeploymentCompose {
|
||||||
stack.EntryPoint = filesystem.ComposeFileDefaultName
|
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 {
|
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 {
|
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 {
|
return composePath, manifestPath, projectPath, nil
|
||||||
hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to check for existence of docker endpoint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
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 hasDockerEndpoint {
|
||||||
if err != nil {
|
return "", "", "", fmt.Errorf("edge stack with docker environment cannot be deployed with kubernetes or nomad config")
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
type swarmStackFromGitRepositoryPayload struct {
|
||||||
|
@ -200,10 +190,13 @@ type swarmStackFromGitRepositoryPayload struct {
|
||||||
// List of identifiers of EdgeGroups
|
// List of identifiers of EdgeGroups
|
||||||
EdgeGroups []portainer.EdgeGroupID `example:"1"`
|
EdgeGroups []portainer.EdgeGroupID `example:"1"`
|
||||||
// Deployment type to deploy this stack
|
// 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)
|
// for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints)
|
||||||
// kubernetes deploytype is enabled only for kubernetes environments(endpoints)
|
// kubernetes deploy type is enabled only for kubernetes environments(endpoints)
|
||||||
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"`
|
// 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 {
|
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"}
|
return &InvalidPayloadError{msg: "Invalid repository credentials. Password must be specified when authentication is enabled"}
|
||||||
}
|
}
|
||||||
if govalidator.IsNull(payload.FilePathInRepository) {
|
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 {
|
if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 {
|
||||||
return &InvalidPayloadError{msg: "Edge Groups are mandatory for an Edge stack"}
|
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
|
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
|
var payload swarmStackFromGitRepositoryPayload
|
||||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.validateUniqueName(payload.Name)
|
stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, errors.Wrap(err, "failed to create edge stack object")
|
||||||
}
|
}
|
||||||
|
|
||||||
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
if dryrun {
|
||||||
stack := &portainer.EdgeStack{
|
return stack, nil
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID)))
|
repoConfig := gittypes.RepoConfig{
|
||||||
stack.ProjectPath = projectPath
|
URL: payload.RepositoryURL,
|
||||||
|
ReferenceName: payload.RepositoryReferenceName,
|
||||||
repositoryUsername := payload.RepositoryUsername
|
|
||||||
repositoryPassword := payload.RepositoryPassword
|
|
||||||
if !payload.RepositoryAuthentication {
|
|
||||||
repositoryUsername = ""
|
|
||||||
repositoryPassword = ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
if payload.RepositoryAuthentication {
|
||||||
if err != nil {
|
repoConfig.Authentication = &gittypes.GitAuthentication{
|
||||||
return nil, fmt.Errorf("failed fetching relations config: %w", err)
|
Username: payload.RepositoryUsername,
|
||||||
}
|
Password: payload.RepositoryPassword,
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
stack.ManifestPath = payload.FilePathInRepository
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds)
|
return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||||
if err != nil {
|
return handler.storeManifestFromGitRepository(stackFolder, relatedEndpointIds, payload.DeploymentType, userID, repoConfig)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type swarmStackFromFileUploadPayload struct {
|
type swarmStackFromFileUploadPayload struct {
|
||||||
Name string
|
Name string
|
||||||
StackFileContent []byte
|
StackFileContent []byte
|
||||||
EdgeGroups []portainer.EdgeGroupID
|
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 {
|
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)
|
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
|
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{}
|
payload := &swarmStackFromFileUploadPayload{}
|
||||||
err := payload.Validate(r)
|
err := payload.Validate(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.validateUniqueName(payload.Name)
|
stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, errors.Wrap(err, "failed to create edge stack object")
|
||||||
}
|
}
|
||||||
|
|
||||||
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
if dryrun {
|
||||||
stack := &portainer.EdgeStack{
|
return stack, nil
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||||
if err != nil {
|
return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, payload.StackFileContent)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *Handler) validateUniqueName(name string) error {
|
func (handler *Handler) storeManifestFromGitRepository(stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) {
|
||||||
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
|
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 {
|
if err != nil {
|
||||||
return err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, stack := range edgeStacks {
|
if deploymentType == portainer.EdgeStackDeploymentCompose {
|
||||||
if strings.EqualFold(stack.Name, name) {
|
composePath := repositoryConfig.ConfigFilePath
|
||||||
return errors.New("Edge stack name must be unique")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateEndpointRelations adds a relation between the Edge Stack to the related environments(endpoints)
|
manifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, projectPath, composePath, relatedEndpointIds)
|
||||||
func updateEndpointRelations(endpointRelationService dataservices.EndpointRelationService, edgeStackID portainer.EdgeStackID, relatedEndpointIds []portainer.EndpointID) error {
|
|
||||||
for _, endpointID := range relatedEndpointIds {
|
|
||||||
relation, err := endpointRelationService.EndpointRelation(endpointID)
|
|
||||||
if err != nil {
|
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
|
return composePath, manifestPath, projectPath, nil
|
||||||
|
|
||||||
err = endpointRelationService.UpdateEndpointRelation(endpointID, relation)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to persist environment relation in database: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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/request"
|
||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// @id EdgeStackDelete
|
// @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)
|
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 {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to remove the edge stack from the database", err)
|
return httperror.InternalServerError("Unable to delete edge stack", 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 response.Empty(w)
|
return response.Empty(w)
|
||||||
|
|
|
@ -14,19 +14,22 @@ import (
|
||||||
type updateStatusPayload struct {
|
type updateStatusPayload struct {
|
||||||
Error string
|
Error string
|
||||||
Status *portainer.EdgeStackStatusType
|
Status *portainer.EdgeStackStatusType
|
||||||
EndpointID *portainer.EndpointID
|
EndpointID portainer.EndpointID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *updateStatusPayload) Validate(r *http.Request) error {
|
func (payload *updateStatusPayload) Validate(r *http.Request) error {
|
||||||
if payload.Status == nil {
|
if payload.Status == nil {
|
||||||
return errors.New("Invalid status")
|
return errors.New("Invalid status")
|
||||||
}
|
}
|
||||||
if payload.EndpointID == nil {
|
|
||||||
|
if payload.EndpointID == 0 {
|
||||||
return errors.New("Invalid EnvironmentID")
|
return errors.New("Invalid EnvironmentID")
|
||||||
}
|
}
|
||||||
|
|
||||||
if *payload.Status == portainer.StatusError && govalidator.IsNull(payload.Error) {
|
if *payload.Status == portainer.StatusError && govalidator.IsNull(payload.Error) {
|
||||||
return errors.New("Error message is mandatory when status is error")
|
return errors.New("Error message is mandatory when status is error")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,7 +65,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
|
||||||
return httperror.BadRequest("Invalid request payload", err)
|
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) {
|
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||||
} else if err != nil {
|
} 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)
|
return httperror.Forbidden("Permission denied to access environment", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.Status[*payload.EndpointID] = portainer.EdgeStackStatus{
|
stack.Status[payload.EndpointID] = portainer.EdgeStackStatus{
|
||||||
Type: *payload.Status,
|
Type: *payload.Status,
|
||||||
Error: payload.Error,
|
Error: payload.Error,
|
||||||
EndpointID: *payload.EndpointID,
|
EndpointID: payload.EndpointID,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
||||||
|
|
|
@ -17,6 +17,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/datastore"
|
"github.com/portainer/portainer/api/datastore"
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||||
"github.com/portainer/portainer/api/jwt"
|
"github.com/portainer/portainer/api/jwt"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
@ -69,9 +70,12 @@ func setupHandler(t *testing.T) (*Handler, string, func()) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
edgeStacksService := edgestacks.NewService(store)
|
||||||
|
|
||||||
handler := NewHandler(
|
handler := NewHandler(
|
||||||
security.NewRequestBouncer(store, jwtService, apiKeyService),
|
security.NewRequestBouncer(store, jwtService, apiKeyService),
|
||||||
store,
|
store,
|
||||||
|
edgeStacksService,
|
||||||
)
|
)
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
@ -779,7 +783,7 @@ func TestUpdateStatusAndInspect(t *testing.T) {
|
||||||
payload := updateStatusPayload{
|
payload := updateStatusPayload{
|
||||||
Error: "test-error",
|
Error: "test-error",
|
||||||
Status: &newStatus,
|
Status: &newStatus,
|
||||||
EndpointID: &endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonPayload, err := json.Marshal(payload)
|
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))
|
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))
|
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{
|
updateStatusPayload{
|
||||||
Error: "test-error",
|
Error: "test-error",
|
||||||
Status: nil,
|
Status: nil,
|
||||||
EndpointID: &endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
},
|
},
|
||||||
"Invalid status",
|
"Invalid status",
|
||||||
400,
|
400,
|
||||||
|
@ -864,17 +868,17 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) {
|
||||||
updateStatusPayload{
|
updateStatusPayload{
|
||||||
Error: "",
|
Error: "",
|
||||||
Status: &statusError,
|
Status: &statusError,
|
||||||
EndpointID: &endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
},
|
},
|
||||||
"Error message is mandatory when status is error",
|
"Error message is mandatory when status is error",
|
||||||
400,
|
400,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Update with nil EndpointID",
|
"Update with missing EndpointID",
|
||||||
updateStatusPayload{
|
updateStatusPayload{
|
||||||
Error: "",
|
Error: "",
|
||||||
Status: &statusOk,
|
Status: &statusOk,
|
||||||
EndpointID: nil,
|
EndpointID: 0,
|
||||||
},
|
},
|
||||||
"Invalid EnvironmentID",
|
"Invalid EnvironmentID",
|
||||||
400,
|
400,
|
||||||
|
|
|
@ -65,18 +65,20 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
||||||
return httperror.BadRequest("Invalid request payload", err)
|
return httperror.BadRequest("Invalid request payload", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
relationConfig, err := edge.FetchEndpointRelationsConfig(handler.DataStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to retrieve environments relations config from database", err)
|
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 {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
|
return httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpointsToAdd := map[portainer.EndpointID]bool{}
|
||||||
|
|
||||||
if payload.EdgeGroups != nil {
|
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 {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
|
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 {
|
for endpointID := range newRelatedSet {
|
||||||
if !oldRelatedSet[endpointID] {
|
if !oldRelatedSet[endpointID] {
|
||||||
endpointsToAdd[endpointID] = true
|
endpointsToAdd[endpointID] = true
|
||||||
|
@ -143,22 +144,26 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
|
|
||||||
if payload.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
if payload.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
||||||
if stack.EntryPoint == "" {
|
if stack.EntryPoint == "" {
|
||||||
stack.EntryPoint = filesystem.ComposeFileDefaultName
|
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 {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
|
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 {
|
if err != nil {
|
||||||
return httperror.InternalServerError("Unable to convert and persist updated Kubernetes manifest file on disk", err)
|
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 == "" {
|
if stack.ManifestPath == "" {
|
||||||
stack.ManifestPath = filesystem.ManifestFileDefaultName
|
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))
|
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
|
||||||
if err != nil {
|
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.Version = *payload.Version
|
||||||
stack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{}
|
stack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,32 +30,3 @@ func hasEndpointPredicate(endpointService dataservices.EndpointService, endpoint
|
||||||
|
|
||||||
return false, nil
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
@ -12,6 +11,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/http/middlewares"
|
"github.com/portainer/portainer/api/http/middlewares"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"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.
|
// Handler is the HTTP handler used to handle environment(endpoint) group operations.
|
||||||
|
@ -21,16 +21,19 @@ type Handler struct {
|
||||||
DataStore dataservices.DataStore
|
DataStore dataservices.DataStore
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
GitService portainer.GitService
|
GitService portainer.GitService
|
||||||
|
edgeStacksService *edgestackservice.Service
|
||||||
KubernetesDeployer portainer.KubernetesDeployer
|
KubernetesDeployer portainer.KubernetesDeployer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
// 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{
|
h := &Handler{
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
requestBouncer: bouncer,
|
requestBouncer: bouncer,
|
||||||
DataStore: dataStore,
|
DataStore: dataStore,
|
||||||
|
edgeStacksService: edgeStacksService,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.Handle("/edge_stacks",
|
h.Handle("/edge_stacks",
|
||||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
|
||||||
h.Handle("/edge_stacks",
|
h.Handle("/edge_stacks",
|
||||||
|
@ -54,33 +57,31 @@ func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataSto
|
||||||
return h
|
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)
|
hasKubeEndpoint, err := hasKubeEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
|
||||||
if err != nil {
|
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 {
|
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 {
|
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)
|
kompose, err := handler.KubernetesDeployer.ConvertCompose(composeConfig)
|
||||||
if err != nil {
|
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
|
komposeFileName := filesystem.ManifestFileDefaultName
|
||||||
_, err = handler.FileService.StoreEdgeStackFileFromBytes(strconv.Itoa(int(edgeStack.ID)), komposeFileName, kompose)
|
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, komposeFileName, kompose)
|
||||||
if err != nil {
|
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 komposeFileName, nil
|
||||||
|
|
||||||
return 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/edgejobs"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
"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/endpointedge"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
||||||
|
@ -44,43 +43,42 @@ import (
|
||||||
|
|
||||||
// Handler is a collection of all the service handlers.
|
// Handler is a collection of all the service handlers.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
AuthHandler *auth.Handler
|
AuthHandler *auth.Handler
|
||||||
BackupHandler *backup.Handler
|
BackupHandler *backup.Handler
|
||||||
CustomTemplatesHandler *customtemplates.Handler
|
CustomTemplatesHandler *customtemplates.Handler
|
||||||
DockerHandler *docker.Handler
|
DockerHandler *docker.Handler
|
||||||
EdgeGroupsHandler *edgegroups.Handler
|
EdgeGroupsHandler *edgegroups.Handler
|
||||||
EdgeJobsHandler *edgejobs.Handler
|
EdgeJobsHandler *edgejobs.Handler
|
||||||
EdgeUpdateScheduleHandler *edgeupdateschedules.Handler
|
EdgeStacksHandler *edgestacks.Handler
|
||||||
EdgeStacksHandler *edgestacks.Handler
|
EdgeTemplatesHandler *edgetemplates.Handler
|
||||||
EdgeTemplatesHandler *edgetemplates.Handler
|
EndpointEdgeHandler *endpointedge.Handler
|
||||||
EndpointEdgeHandler *endpointedge.Handler
|
EndpointGroupHandler *endpointgroups.Handler
|
||||||
EndpointGroupHandler *endpointgroups.Handler
|
EndpointHandler *endpoints.Handler
|
||||||
EndpointHandler *endpoints.Handler
|
EndpointHelmHandler *helm.Handler
|
||||||
EndpointHelmHandler *helm.Handler
|
EndpointProxyHandler *endpointproxy.Handler
|
||||||
EndpointProxyHandler *endpointproxy.Handler
|
HelmTemplatesHandler *helm.Handler
|
||||||
HelmTemplatesHandler *helm.Handler
|
KubernetesHandler *kubernetes.Handler
|
||||||
KubernetesHandler *kubernetes.Handler
|
FileHandler *file.Handler
|
||||||
FileHandler *file.Handler
|
LDAPHandler *ldap.Handler
|
||||||
LDAPHandler *ldap.Handler
|
MOTDHandler *motd.Handler
|
||||||
MOTDHandler *motd.Handler
|
RegistryHandler *registries.Handler
|
||||||
RegistryHandler *registries.Handler
|
ResourceControlHandler *resourcecontrols.Handler
|
||||||
ResourceControlHandler *resourcecontrols.Handler
|
RoleHandler *roles.Handler
|
||||||
RoleHandler *roles.Handler
|
SettingsHandler *settings.Handler
|
||||||
SettingsHandler *settings.Handler
|
SSLHandler *ssl.Handler
|
||||||
SSLHandler *ssl.Handler
|
OpenAMTHandler *openamt.Handler
|
||||||
OpenAMTHandler *openamt.Handler
|
FDOHandler *fdo.Handler
|
||||||
FDOHandler *fdo.Handler
|
StackHandler *stacks.Handler
|
||||||
StackHandler *stacks.Handler
|
StatusHandler *status.Handler
|
||||||
StatusHandler *status.Handler
|
StorybookHandler *storybook.Handler
|
||||||
StorybookHandler *storybook.Handler
|
TagHandler *tags.Handler
|
||||||
TagHandler *tags.Handler
|
TeamMembershipHandler *teammemberships.Handler
|
||||||
TeamMembershipHandler *teammemberships.Handler
|
TeamHandler *teams.Handler
|
||||||
TeamHandler *teams.Handler
|
TemplatesHandler *templates.Handler
|
||||||
TemplatesHandler *templates.Handler
|
UploadHandler *upload.Handler
|
||||||
UploadHandler *upload.Handler
|
UserHandler *users.Handler
|
||||||
UserHandler *users.Handler
|
WebSocketHandler *websocket.Handler
|
||||||
WebSocketHandler *websocket.Handler
|
WebhookHandler *webhooks.Handler
|
||||||
WebhookHandler *webhooks.Handler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @title PortainerCE API
|
// @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)
|
http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/custom_templates"):
|
case strings.HasPrefix(r.URL.Path, "/api/custom_templates"):
|
||||||
http.StripPrefix("/api", h.CustomTemplatesHandler).ServeHTTP(w, r)
|
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"):
|
case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"):
|
||||||
http.StripPrefix("/api", h.EdgeStacksHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.EdgeStacksHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/edge_groups"):
|
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/edgejobs"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
"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/endpointedge"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
"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/proxy/factory/kubernetes"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
|
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||||
"github.com/portainer/portainer/api/internal/ssl"
|
"github.com/portainer/portainer/api/internal/ssl"
|
||||||
k8s "github.com/portainer/portainer/api/kubernetes"
|
k8s "github.com/portainer/portainer/api/kubernetes"
|
||||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||||
|
@ -77,6 +77,7 @@ type Server struct {
|
||||||
ReverseTunnelService portainer.ReverseTunnelService
|
ReverseTunnelService portainer.ReverseTunnelService
|
||||||
ComposeStackManager portainer.ComposeStackManager
|
ComposeStackManager portainer.ComposeStackManager
|
||||||
CryptoService portainer.CryptoService
|
CryptoService portainer.CryptoService
|
||||||
|
EdgeStacksService *edgestackservice.Service
|
||||||
SignatureService portainer.DigitalSignatureService
|
SignatureService portainer.DigitalSignatureService
|
||||||
SnapshotService portainer.SnapshotService
|
SnapshotService portainer.SnapshotService
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
|
@ -153,9 +154,7 @@ func (server *Server) Start() error {
|
||||||
edgeJobsHandler.FileService = server.FileService
|
edgeJobsHandler.FileService = server.FileService
|
||||||
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
|
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||||
|
|
||||||
edgeUpdateScheduleHandler := edgeupdateschedules.NewHandler(requestBouncer, server.DataStore)
|
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService)
|
||||||
|
|
||||||
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore)
|
|
||||||
edgeStacksHandler.FileService = server.FileService
|
edgeStacksHandler.FileService = server.FileService
|
||||||
edgeStacksHandler.GitService = server.GitService
|
edgeStacksHandler.GitService = server.GitService
|
||||||
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
|
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
|
||||||
|
@ -277,43 +276,42 @@ func (server *Server) Start() error {
|
||||||
webhookHandler.DockerClientFactory = server.DockerClientFactory
|
webhookHandler.DockerClientFactory = server.DockerClientFactory
|
||||||
|
|
||||||
server.Handler = &handler.Handler{
|
server.Handler = &handler.Handler{
|
||||||
RoleHandler: roleHandler,
|
RoleHandler: roleHandler,
|
||||||
AuthHandler: authHandler,
|
AuthHandler: authHandler,
|
||||||
BackupHandler: backupHandler,
|
BackupHandler: backupHandler,
|
||||||
CustomTemplatesHandler: customTemplatesHandler,
|
CustomTemplatesHandler: customTemplatesHandler,
|
||||||
DockerHandler: dockerHandler,
|
DockerHandler: dockerHandler,
|
||||||
EdgeGroupsHandler: edgeGroupsHandler,
|
EdgeGroupsHandler: edgeGroupsHandler,
|
||||||
EdgeJobsHandler: edgeJobsHandler,
|
EdgeJobsHandler: edgeJobsHandler,
|
||||||
EdgeUpdateScheduleHandler: edgeUpdateScheduleHandler,
|
EdgeStacksHandler: edgeStacksHandler,
|
||||||
EdgeStacksHandler: edgeStacksHandler,
|
EdgeTemplatesHandler: edgeTemplatesHandler,
|
||||||
EdgeTemplatesHandler: edgeTemplatesHandler,
|
EndpointGroupHandler: endpointGroupHandler,
|
||||||
EndpointGroupHandler: endpointGroupHandler,
|
EndpointHandler: endpointHandler,
|
||||||
EndpointHandler: endpointHandler,
|
EndpointHelmHandler: endpointHelmHandler,
|
||||||
EndpointHelmHandler: endpointHelmHandler,
|
EndpointEdgeHandler: endpointEdgeHandler,
|
||||||
EndpointEdgeHandler: endpointEdgeHandler,
|
EndpointProxyHandler: endpointProxyHandler,
|
||||||
EndpointProxyHandler: endpointProxyHandler,
|
FileHandler: fileHandler,
|
||||||
FileHandler: fileHandler,
|
LDAPHandler: ldapHandler,
|
||||||
LDAPHandler: ldapHandler,
|
HelmTemplatesHandler: helmTemplatesHandler,
|
||||||
HelmTemplatesHandler: helmTemplatesHandler,
|
KubernetesHandler: kubernetesHandler,
|
||||||
KubernetesHandler: kubernetesHandler,
|
MOTDHandler: motdHandler,
|
||||||
MOTDHandler: motdHandler,
|
OpenAMTHandler: openAMTHandler,
|
||||||
OpenAMTHandler: openAMTHandler,
|
FDOHandler: fdoHandler,
|
||||||
FDOHandler: fdoHandler,
|
RegistryHandler: registryHandler,
|
||||||
RegistryHandler: registryHandler,
|
ResourceControlHandler: resourceControlHandler,
|
||||||
ResourceControlHandler: resourceControlHandler,
|
SettingsHandler: settingsHandler,
|
||||||
SettingsHandler: settingsHandler,
|
SSLHandler: sslHandler,
|
||||||
SSLHandler: sslHandler,
|
StatusHandler: statusHandler,
|
||||||
StatusHandler: statusHandler,
|
StackHandler: stackHandler,
|
||||||
StackHandler: stackHandler,
|
StorybookHandler: storybookHandler,
|
||||||
StorybookHandler: storybookHandler,
|
TagHandler: tagHandler,
|
||||||
TagHandler: tagHandler,
|
TeamHandler: teamHandler,
|
||||||
TeamHandler: teamHandler,
|
TeamMembershipHandler: teamMembershipHandler,
|
||||||
TeamMembershipHandler: teamMembershipHandler,
|
TemplatesHandler: templatesHandler,
|
||||||
TemplatesHandler: templatesHandler,
|
UploadHandler: uploadHandler,
|
||||||
UploadHandler: uploadHandler,
|
UserHandler: userHandler,
|
||||||
UserHandler: userHandler,
|
WebSocketHandler: websocketHandler,
|
||||||
WebSocketHandler: websocketHandler,
|
WebhookHandler: webhookHandler,
|
||||||
WebhookHandler: webhookHandler,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler))
|
handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler))
|
||||||
|
|
|
@ -2,8 +2,10 @@ package edge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
)
|
)
|
||||||
|
|
||||||
// EdgeStackRelatedEndpoints returns a list of environments(endpoints) related to this Edge stack
|
// 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
|
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))
|
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")
|
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
|
customTemplate dataservices.CustomTemplateService
|
||||||
edgeGroup dataservices.EdgeGroupService
|
edgeGroup dataservices.EdgeGroupService
|
||||||
edgeJob dataservices.EdgeJobService
|
edgeJob dataservices.EdgeJobService
|
||||||
edgeUpdateSchedule dataservices.EdgeUpdateScheduleService
|
|
||||||
edgeStack dataservices.EdgeStackService
|
edgeStack dataservices.EdgeStackService
|
||||||
endpoint dataservices.EndpointService
|
endpoint dataservices.EndpointService
|
||||||
endpointGroup dataservices.EndpointGroupService
|
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) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
|
||||||
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
|
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
|
||||||
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
|
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
|
||||||
func (d *testDatastore) EdgeUpdateSchedule() dataservices.EdgeUpdateScheduleService {
|
|
||||||
return d.edgeUpdateSchedule
|
|
||||||
}
|
|
||||||
func (d *testDatastore) FDOProfile() dataservices.FDOProfileService {
|
func (d *testDatastore) FDOProfile() dataservices.FDOProfileService {
|
||||||
return d.fdoProfile
|
return d.fdoProfile
|
||||||
}
|
}
|
||||||
|
|
|
@ -1500,12 +1500,10 @@ const (
|
||||||
WebSocketKeepAlive = 1 * time.Hour
|
WebSocketKeepAlive = 1 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
const FeatureFlagEdgeRemoteUpdate Feature = "edgeRemoteUpdate"
|
|
||||||
const FeatureFlagBEUpgrade = "beUpgrade"
|
const FeatureFlagBEUpgrade = "beUpgrade"
|
||||||
|
|
||||||
// List of supported features
|
// List of supported features
|
||||||
var SupportedFeatureFlags = []Feature{
|
var SupportedFeatureFlags = []Feature{
|
||||||
FeatureFlagEdgeRemoteUpdate,
|
|
||||||
FeatureFlagBEUpgrade,
|
FeatureFlagBEUpgrade,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,13 +37,18 @@ export function humanize(bytes, round, base) {
|
||||||
return filesize(bytes, { base: base, round: round });
|
return filesize(bytes, { base: base, round: round });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||||
|
|
||||||
export function isoDateFromTimestamp(timestamp) {
|
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) {
|
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) {
|
export function getPairKey(pair, separator) {
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { PropsWithChildren, AnchorHTMLAttributes } from 'react';
|
import { PropsWithChildren, AnchorHTMLAttributes } from 'react';
|
||||||
import { UISref, UISrefProps } from '@uirouter/react';
|
import { UISref, UISrefProps } from '@uirouter/react';
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
@ -15,7 +14,7 @@ export function Link({
|
||||||
}: PropsWithChildren<Props> & UISrefProps) {
|
}: PropsWithChildren<Props> & UISrefProps) {
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// 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 */}
|
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||||
<a title={title} target={props.target}>
|
<a title={title} target={props.target}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -61,9 +61,7 @@ export function Button({
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
{...ariaProps}
|
{...ariaProps}
|
||||||
>
|
>
|
||||||
{icon && (
|
{icon && <Icon icon={icon} size={getIconSize(size)} />}
|
||||||
<Icon icon={icon} size={getIconSize(size)} className="inline-flex" />
|
|
||||||
)}
|
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { useRouter } from '@uirouter/react';
|
|
||||||
|
|
||||||
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
|
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||||
import { EdgeTypes } from '@/react/portainer/environments/types';
|
import { EdgeTypes } from '@/react/portainer/environments/types';
|
||||||
|
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||||
|
|
||||||
import { InformationPanel } from '@@/InformationPanel';
|
import { InformationPanel } from '@@/InformationPanel';
|
||||||
import { TextTip } from '@@/Tip/TextTip';
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
@ -9,8 +8,9 @@ import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
||||||
import { Datatable } from './Datatable';
|
import { Datatable } from './Datatable';
|
||||||
|
|
||||||
export function WaitingRoomView() {
|
export default withLimitToBE(WaitingRoomView);
|
||||||
const router = useRouter();
|
|
||||||
|
function WaitingRoomView() {
|
||||||
const { environments, isLoading, totalCount } = useEnvironmentList({
|
const { environments, isLoading, totalCount } = useEnvironmentList({
|
||||||
edgeDevice: true,
|
edgeDevice: true,
|
||||||
edgeDeviceUntrusted: true,
|
edgeDeviceUntrusted: true,
|
||||||
|
@ -18,11 +18,6 @@ export function WaitingRoomView() {
|
||||||
types: EdgeTypes,
|
types: EdgeTypes,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (process.env.PORTAINER_EDITION !== 'BE') {
|
|
||||||
router.stateService.go('edge.devices');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<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>
|
||||||
|
|
||||||
<Authorized authorizations="K8sIngressesW">
|
<Authorized authorizations="K8sIngressesW">
|
||||||
<Link to="kubernetes.ingresses.create" className="space-left">
|
<Link
|
||||||
|
to="kubernetes.ingresses.create"
|
||||||
|
className="space-left no-decoration"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
icon={Plus}
|
icon={Plus}
|
||||||
className="btn-wrapper vertical-center"
|
className="btn-wrapper vertical-center"
|
||||||
|
@ -92,7 +95,7 @@ export function IngressDatatable() {
|
||||||
</Link>
|
</Link>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<Authorized authorizations="K8sIngressesW">
|
<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">
|
<Button icon={Plus} className="btn-wrapper">
|
||||||
Create from manifest
|
Create from manifest
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -40,6 +40,7 @@ import { EnvironmentItem } from './EnvironmentItem';
|
||||||
import { KubeconfigButton } from './KubeconfigButton';
|
import { KubeconfigButton } from './KubeconfigButton';
|
||||||
import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel';
|
import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel';
|
||||||
import styles from './EnvironmentList.module.css';
|
import styles from './EnvironmentList.module.css';
|
||||||
|
import { UpdateBadge } from './UpdateBadge';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClickItem(environment: Environment): void;
|
onClickItem(environment: Environment): void;
|
||||||
|
@ -131,21 +132,27 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||||
edgeDevice: false,
|
edgeDevice: false,
|
||||||
tagsPartialMatch: true,
|
tagsPartialMatch: true,
|
||||||
agentVersions: agentVersions.map((a) => a.value),
|
agentVersions: agentVersions.map((a) => a.value),
|
||||||
|
updateInformation: isBE,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tagsQuery = useTags();
|
const tagsQuery = useTags();
|
||||||
|
|
||||||
const { isLoading, environments, totalCount, totalAvailable } =
|
const {
|
||||||
useEnvironmentList(
|
isLoading,
|
||||||
{
|
environments,
|
||||||
page,
|
totalCount,
|
||||||
pageLimit,
|
totalAvailable,
|
||||||
sort: sortByFilter,
|
updateAvailable,
|
||||||
order: sortByDescending ? 'desc' : 'asc',
|
} = useEnvironmentList(
|
||||||
...environmentsQueryParams,
|
{
|
||||||
},
|
page,
|
||||||
refetchIfAnyOffline
|
pageLimit,
|
||||||
);
|
sort: sortByFilter,
|
||||||
|
order: sortByDescending ? 'desc' : 'asc',
|
||||||
|
...environmentsQueryParams,
|
||||||
|
},
|
||||||
|
refetchIfAnyOffline
|
||||||
|
);
|
||||||
|
|
||||||
const agentVersionsQuery = useAgentVersionsList();
|
const agentVersionsQuery = useAgentVersionsList();
|
||||||
|
|
||||||
|
@ -175,9 +182,10 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
|
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
|
||||||
|
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<TableTitle icon={HardDrive} label="Environments" />
|
<TableTitle icon={HardDrive} label="Environments">
|
||||||
|
{isBE && updateAvailable && <UpdateBadge />}
|
||||||
|
</TableTitle>
|
||||||
|
|
||||||
<TableActions className={styles.actionBar}>
|
<TableActions className={styles.actionBar}>
|
||||||
<div className={styles.description}>
|
<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;
|
provisioned?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
agentVersions?: string[];
|
agentVersions?: string[];
|
||||||
|
updateInformation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetEnvironmentsOptions {
|
export interface GetEnvironmentsOptions {
|
||||||
|
@ -46,7 +47,12 @@ export async function getEnvironments(
|
||||||
}: GetEnvironmentsOptions = { query: {} }
|
}: GetEnvironmentsOptions = { query: {} }
|
||||||
) {
|
) {
|
||||||
if (query.tagIds && query.tagIds.length === 0) {
|
if (query.tagIds && query.tagIds.length === 0) {
|
||||||
return { totalCount: 0, value: <Environment[]>[] };
|
return {
|
||||||
|
totalCount: 0,
|
||||||
|
value: <Environment[]>[],
|
||||||
|
totalAvailable: 0,
|
||||||
|
updateAvailable: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = buildUrl();
|
const url = buildUrl();
|
||||||
|
@ -63,11 +69,13 @@ export async function getEnvironments(
|
||||||
const response = await axios.get<Environment[]>(url, { params });
|
const response = await axios.get<Environment[]>(url, { params });
|
||||||
const totalCount = response.headers['x-total-count'];
|
const totalCount = response.headers['x-total-count'];
|
||||||
const totalAvailable = response.headers['x-total-available'];
|
const totalAvailable = response.headers['x-total-available'];
|
||||||
|
const updateAvailable = response.headers['x-update-available'] === 'true';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalCount: parseInt(totalCount, 10),
|
totalCount: parseInt(totalCount, 10),
|
||||||
value: response.data,
|
value: response.data,
|
||||||
totalAvailable: parseInt(totalAvailable, 10),
|
totalAvailable: parseInt(totalAvailable, 10),
|
||||||
|
updateAvailable,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw parseAxiosError(e as Error);
|
throw parseAxiosError(e as Error);
|
||||||
|
|
|
@ -78,5 +78,6 @@ export function useEnvironmentList(
|
||||||
environments: data ? data.value : [],
|
environments: data ? data.value : [],
|
||||||
totalCount: data ? data.totalCount : 0,
|
totalCount: data ? data.totalCount : 0,
|
||||||
totalAvailable: data ? data.totalAvailable : 0,
|
totalAvailable: data ? data.totalAvailable : 0,
|
||||||
|
updateAvailable: data ? data.updateAvailable : false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -131,6 +131,7 @@ export type Environment = {
|
||||||
Edge: EnvironmentEdge;
|
Edge: EnvironmentEdge;
|
||||||
SecuritySettings: EnvironmentSecuritySettings;
|
SecuritySettings: EnvironmentSecuritySettings;
|
||||||
Gpus: { name: string; value: string }[];
|
Gpus: { name: string; value: string }[];
|
||||||
|
LocalTimeZone?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -3,14 +3,13 @@ import { Formik, Form as FormikForm } from 'formik';
|
||||||
import { useRouter } from '@uirouter/react';
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
import {
|
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||||
useRedirectFeatureFlag,
|
import { isoDate } from '@/portainer/filters/filters';
|
||||||
FeatureFlag,
|
|
||||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
|
||||||
|
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
import { Widget } from '@@/Widget';
|
import { Widget } from '@@/Widget';
|
||||||
import { LoadingButton } from '@@/buttons';
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
import { ScheduleType } from '../types';
|
import { ScheduleType } from '../types';
|
||||||
import { useCreateMutation } from '../queries/create';
|
import { useCreateMutation } from '../queries/create';
|
||||||
|
@ -18,19 +17,21 @@ import { FormValues } from '../common/types';
|
||||||
import { validation } from '../common/validation';
|
import { validation } from '../common/validation';
|
||||||
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||||
import { useList } from '../queries/list';
|
import { useList } from '../queries/list';
|
||||||
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
|
||||||
import { NameField } from '../common/NameField';
|
import { NameField } from '../common/NameField';
|
||||||
|
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||||
|
import { BetaAlert } from '../common/BetaAlert';
|
||||||
|
|
||||||
const initialValues: FormValues = {
|
export default withLimitToBE(CreateView);
|
||||||
name: '',
|
|
||||||
groupIds: [],
|
function CreateView() {
|
||||||
type: ScheduleType.Update,
|
const initialValues: FormValues = {
|
||||||
time: Math.floor(Date.now() / 1000) + 60 * 60,
|
name: '',
|
||||||
environments: {},
|
groupIds: [],
|
||||||
};
|
type: ScheduleType.Update,
|
||||||
|
version: '',
|
||||||
|
scheduledTime: isoDate(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
export function CreateView() {
|
|
||||||
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
|
||||||
const schedulesQuery = useList();
|
const schedulesQuery = useList();
|
||||||
|
|
||||||
const createMutation = useCreateMutation();
|
const createMutation = useCreateMutation();
|
||||||
|
@ -49,6 +50,8 @@ export function CreateView() {
|
||||||
breadcrumbs="Edge agent update and rollback"
|
breadcrumbs="Edge agent update and rollback"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BetaAlert />
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<Widget>
|
<Widget>
|
||||||
|
@ -60,11 +63,25 @@ export function CreateView() {
|
||||||
validateOnMount
|
validateOnMount
|
||||||
validationSchema={() => validation(schedules)}
|
validationSchema={() => validation(schedules)}
|
||||||
>
|
>
|
||||||
{({ isValid }) => (
|
{({ isValid, setFieldValue, values, handleBlur, errors }) => (
|
||||||
<FormikForm className="form-horizontal">
|
<FormikForm className="form-horizontal">
|
||||||
<NameField />
|
<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 />
|
<ScheduleTypeSelector />
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
export { CreateView } from './CreateView';
|
export { default as CreateView } from './CreateView';
|
||||||
|
|
|
@ -1,20 +1,17 @@
|
||||||
import { Settings } from 'lucide-react';
|
import { Settings } from 'lucide-react';
|
||||||
import { Formik, Form as FormikForm } from 'formik';
|
import { Formik, Form as FormikForm } from 'formik';
|
||||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { object, SchemaOf } from 'yup';
|
import { object, SchemaOf } from 'yup';
|
||||||
|
|
||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
import {
|
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||||
useRedirectFeatureFlag,
|
|
||||||
FeatureFlag,
|
|
||||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
|
||||||
|
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
import { Widget } from '@@/Widget';
|
import { Widget } from '@@/Widget';
|
||||||
import { LoadingButton } from '@@/buttons';
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { InformationPanel } from '@@/InformationPanel';
|
||||||
|
|
||||||
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
|
||||||
import { useItem } from '../queries/useItem';
|
import { useItem } from '../queries/useItem';
|
||||||
import { validation } from '../common/validation';
|
import { validation } from '../common/validation';
|
||||||
import { useUpdateMutation } from '../queries/useUpdateMutation';
|
import { useUpdateMutation } from '../queries/useUpdateMutation';
|
||||||
|
@ -23,12 +20,12 @@ import { NameField, nameValidation } from '../common/NameField';
|
||||||
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||||
import { EdgeUpdateSchedule } from '../types';
|
import { EdgeUpdateSchedule } from '../types';
|
||||||
import { FormValues } from '../common/types';
|
import { FormValues } from '../common/types';
|
||||||
|
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||||
|
import { BetaAlert } from '../common/BetaAlert';
|
||||||
|
|
||||||
import { ScheduleDetails } from './ScheduleDetails';
|
export default withLimitToBE(ItemView);
|
||||||
|
|
||||||
export function ItemView() {
|
|
||||||
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
|
||||||
|
|
||||||
|
function ItemView() {
|
||||||
const {
|
const {
|
||||||
params: { id: idParam },
|
params: { id: idParam },
|
||||||
} = useCurrentStateAndParams();
|
} = useCurrentStateAndParams();
|
||||||
|
@ -44,31 +41,31 @@ export function ItemView() {
|
||||||
const itemQuery = useItem(id);
|
const itemQuery = useItem(id);
|
||||||
const schedulesQuery = useList();
|
const schedulesQuery = useList();
|
||||||
|
|
||||||
const isDisabled = useMemo(
|
|
||||||
() => (itemQuery.data ? itemQuery.data.time < Date.now() / 1000 : false),
|
|
||||||
[itemQuery.data]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!itemQuery.data || !schedulesQuery.data) {
|
if (!itemQuery.data || !schedulesQuery.data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = itemQuery.data;
|
const item = itemQuery.data;
|
||||||
|
const isScheduleActive = item.isActive;
|
||||||
|
|
||||||
const schedules = schedulesQuery.data;
|
const schedules = schedulesQuery.data;
|
||||||
|
|
||||||
|
const initialValuesActive: Partial<FormValues> = {
|
||||||
|
name: item.name,
|
||||||
|
};
|
||||||
|
|
||||||
const initialValues: FormValues = {
|
const initialValues: FormValues = {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
groupIds: item.groupIds,
|
groupIds: item.edgeGroupIds,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
time: item.time,
|
version: item.version,
|
||||||
environments: Object.fromEntries(
|
scheduledTime: item.scheduledTime,
|
||||||
Object.entries(item.status).map(([envId, status]) => [
|
|
||||||
parseInt(envId, 10),
|
|
||||||
status.targetVersion,
|
|
||||||
])
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const environmentsCount = Object.keys(
|
||||||
|
item.environmentsPreviousVersions
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
@ -79,13 +76,17 @@ export function ItemView() {
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BetaAlert />
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-sm-12">
|
<div className="col-sm-12">
|
||||||
<Widget>
|
<Widget>
|
||||||
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
|
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
|
||||||
<Widget.Body>
|
<Widget.Body>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={
|
||||||
|
!isScheduleActive ? initialValues : initialValuesActive
|
||||||
|
}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
updateMutation.mutate(
|
updateMutation.mutate(
|
||||||
{ id, values },
|
{ id, values },
|
||||||
|
@ -102,17 +103,33 @@ export function ItemView() {
|
||||||
}}
|
}}
|
||||||
validateOnMount
|
validateOnMount
|
||||||
validationSchema={() =>
|
validationSchema={() =>
|
||||||
updateValidation(item.id, item.time, schedules)
|
updateValidation(item.id, schedules, isScheduleActive)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isValid }) => (
|
{({ isValid, setFieldValue, values, handleBlur, errors }) => (
|
||||||
<FormikForm className="form-horizontal">
|
<FormikForm className="form-horizontal">
|
||||||
<NameField />
|
<NameField />
|
||||||
|
|
||||||
<EdgeGroupsField disabled={isDisabled} />
|
<EdgeGroupsField
|
||||||
|
disabled={isScheduleActive}
|
||||||
|
onChange={(value) => setFieldValue('groupIds', value)}
|
||||||
|
value={
|
||||||
|
isScheduleActive
|
||||||
|
? item.edgeGroupIds
|
||||||
|
: values.groupIds || []
|
||||||
|
}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
error={errors.groupIds}
|
||||||
|
/>
|
||||||
|
|
||||||
{isDisabled ? (
|
{isScheduleActive ? (
|
||||||
<ScheduleDetails schedule={item} />
|
<InformationPanel>
|
||||||
|
<TextTip color="blue">
|
||||||
|
{environmentsCount} environment(s) will be updated to
|
||||||
|
version {item.version} on {item.scheduledTime} (local
|
||||||
|
time)
|
||||||
|
</TextTip>
|
||||||
|
</InformationPanel>
|
||||||
) : (
|
) : (
|
||||||
<ScheduleTypeSelector />
|
<ScheduleTypeSelector />
|
||||||
)}
|
)}
|
||||||
|
@ -141,10 +158,10 @@ export function ItemView() {
|
||||||
|
|
||||||
function updateValidation(
|
function updateValidation(
|
||||||
itemId: EdgeUpdateSchedule['id'],
|
itemId: EdgeUpdateSchedule['id'],
|
||||||
scheduledTime: number,
|
schedules: EdgeUpdateSchedule[],
|
||||||
schedules: EdgeUpdateSchedule[]
|
isScheduleActive: boolean
|
||||||
): SchemaOf<{ name: string } | FormValues> {
|
): SchemaOf<{ name: string } | FormValues> {
|
||||||
return scheduledTime > Date.now() / 1000
|
return !isScheduleActive
|
||||||
? validation(schedules, itemId)
|
? validation(schedules, itemId)
|
||||||
: object({ name: nameValidation(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 { Clock, Trash2 } from 'lucide-react';
|
||||||
import { useStore } from 'zustand';
|
import { useStore } from 'zustand';
|
||||||
|
|
||||||
import {
|
|
||||||
FeatureFlag,
|
|
||||||
useRedirectFeatureFlag,
|
|
||||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
|
||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||||
|
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||||
|
|
||||||
import { Datatable } from '@@/datatables';
|
import { Datatable } from '@@/datatables';
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
@ -15,7 +12,7 @@ import { Link } from '@@/Link';
|
||||||
import { useSearchBarState } from '@@/datatables/SearchBar';
|
import { useSearchBarState } from '@@/datatables/SearchBar';
|
||||||
|
|
||||||
import { useList } from '../queries/list';
|
import { useList } from '../queries/list';
|
||||||
import { EdgeUpdateSchedule } from '../types';
|
import { EdgeUpdateSchedule, StatusType } from '../types';
|
||||||
import { useRemoveMutation } from '../queries/useRemoveMutation';
|
import { useRemoveMutation } from '../queries/useRemoveMutation';
|
||||||
|
|
||||||
import { columns } from './columns';
|
import { columns } from './columns';
|
||||||
|
@ -24,13 +21,13 @@ import { createStore } from './datatable-store';
|
||||||
const storageKey = 'update-schedules-list';
|
const storageKey = 'update-schedules-list';
|
||||||
const settingsStore = createStore(storageKey);
|
const settingsStore = createStore(storageKey);
|
||||||
|
|
||||||
export function ListView() {
|
export default withLimitToBE(ListView);
|
||||||
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
|
||||||
|
|
||||||
|
export function ListView() {
|
||||||
const settings = useStore(settingsStore);
|
const settings = useStore(settingsStore);
|
||||||
const [search, setSearch] = useSearchBarState(storageKey);
|
const [search, setSearch] = useSearchBarState(storageKey);
|
||||||
|
|
||||||
const listQuery = useList();
|
const listQuery = useList(true);
|
||||||
|
|
||||||
if (!listQuery.data) {
|
if (!listQuery.data) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -61,6 +58,7 @@ export function ListView() {
|
||||||
onSortByChange={settings.setSortBy}
|
onSortByChange={settings.setSortBy}
|
||||||
searchValue={search}
|
searchValue={search}
|
||||||
onSearchChange={setSearch}
|
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 { 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',
|
Header: 'Created',
|
||||||
accessor: (row) => isoDateFromTimestamp(row.created),
|
accessor: (row) => isoDateFromTimestamp(row.created),
|
||||||
disableFilters: true,
|
disableFilters: true,
|
||||||
|
|
|
@ -4,11 +4,11 @@ import _ from 'lodash';
|
||||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
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',
|
Header: 'Groups',
|
||||||
accessor: 'groupIds',
|
accessor: 'edgeGroupIds',
|
||||||
Cell: GroupsCell,
|
Cell: GroupsCell,
|
||||||
disableFilters: true,
|
disableFilters: true,
|
||||||
Filter: () => null,
|
Filter: () => null,
|
||||||
|
@ -18,7 +18,7 @@ export const groups: Column<EdgeUpdateSchedule> = {
|
||||||
|
|
||||||
export function GroupsCell({
|
export function GroupsCell({
|
||||||
value: groupsIds,
|
value: groupsIds,
|
||||||
}: CellProps<EdgeUpdateSchedule, Array<EdgeGroup['Id']>>) {
|
}: CellProps<EdgeUpdateListItemResponse, Array<EdgeGroup['Id']>>) {
|
||||||
const groupsQuery = useEdgeGroups();
|
const groupsQuery = useEdgeGroups();
|
||||||
|
|
||||||
const groups = _.compact(
|
const groups = _.compact(
|
||||||
|
|
|
@ -2,9 +2,9 @@ import { CellProps, Column } from 'react-table';
|
||||||
|
|
||||||
import { Link } from '@@/Link';
|
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',
|
Header: 'Name',
|
||||||
accessor: 'name',
|
accessor: 'name',
|
||||||
id: 'name',
|
id: 'name',
|
||||||
|
@ -15,7 +15,10 @@ export const name: Column<EdgeUpdateSchedule> = {
|
||||||
sortType: 'string',
|
sortType: 'string',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NameCell({ value: name, row }: CellProps<EdgeUpdateSchedule>) {
|
export function NameCell({
|
||||||
|
value: name,
|
||||||
|
row,
|
||||||
|
}: CellProps<EdgeUpdateListItemResponse>) {
|
||||||
return (
|
return (
|
||||||
<Link to=".item" params={{ id: row.original.id }}>
|
<Link to=".item" params={{ id: row.original.id }}>
|
||||||
{name}
|
{name}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { CellProps, Column } from 'react-table';
|
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',
|
Header: 'Status',
|
||||||
accessor: (row) => row.status,
|
accessor: (row) => row.status,
|
||||||
disableFilters: true,
|
disableFilters: true,
|
||||||
|
@ -14,31 +15,17 @@ export const scheduleStatus: Column<EdgeUpdateSchedule> = {
|
||||||
|
|
||||||
function StatusCell({
|
function StatusCell({
|
||||||
value: status,
|
value: status,
|
||||||
row: { original: schedule },
|
row: {
|
||||||
}: CellProps<EdgeUpdateSchedule, EdgeUpdateSchedule['status']>) {
|
original: { statusMessage },
|
||||||
if (schedule.time > Date.now() / 1000) {
|
},
|
||||||
return 'Scheduled';
|
}: 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 { Column } from 'react-table';
|
||||||
|
|
||||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||||
|
|
||||||
import { EdgeUpdateSchedule } from '../../types';
|
export const scheduledTime: Column<EdgeUpdateListItemResponse> = {
|
||||||
|
|
||||||
export const scheduledTime: Column<EdgeUpdateSchedule> = {
|
|
||||||
Header: 'Scheduled Time & Date',
|
Header: 'Scheduled Time & Date',
|
||||||
accessor: (row) => isoDateFromTimestamp(row.time),
|
accessor: (row) => row.scheduledTime,
|
||||||
disableFilters: true,
|
disableFilters: true,
|
||||||
Filter: () => null,
|
Filter: () => null,
|
||||||
canHide: false,
|
canHide: false,
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { Column } from 'react-table';
|
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',
|
Header: 'Type',
|
||||||
accessor: (row) => ScheduleType[row.type],
|
accessor: (row) => ScheduleType[row.type],
|
||||||
disableFilters: true,
|
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';
|
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||||
|
|
||||||
|
@ -9,14 +9,21 @@ import { FormValues } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
disabled?: boolean;
|
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 groupsQuery = useEdgeGroups();
|
||||||
|
|
||||||
const [{ name, onBlur, value }, { error }, { setValue }] =
|
|
||||||
useField<FormValues['groupIds']>('groupIds');
|
|
||||||
|
|
||||||
const selectedGroups = groupsQuery.data?.filter((group) =>
|
const selectedGroups = groupsQuery.data?.filter((group) =>
|
||||||
value.includes(group.Id)
|
value.includes(group.Id)
|
||||||
);
|
);
|
||||||
|
@ -24,12 +31,12 @@ export function EdgeGroupsField({ disabled }: Props) {
|
||||||
return (
|
return (
|
||||||
<FormControl label="Groups" required inputId="groups-select" errors={error}>
|
<FormControl label="Groups" required inputId="groups-select" errors={error}>
|
||||||
<Select
|
<Select
|
||||||
name={name}
|
name="groupIds"
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
value={selectedGroups}
|
value={selectedGroups}
|
||||||
inputId="groups-select"
|
inputId="groups-select"
|
||||||
placeholder="Select one or multiple group(s)"
|
placeholder="Select one or multiple group(s)"
|
||||||
onChange={(selectedGroups) => setValue(selectedGroups.map((g) => g.Id))}
|
onChange={(selectedGroups) => onChange(selectedGroups.map((g) => g.Id))}
|
||||||
isMulti
|
isMulti
|
||||||
options={groupsQuery.data || []}
|
options={groupsQuery.data || []}
|
||||||
getOptionLabel={(group) => group.Name}
|
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 { RollbackOptions } from './RollbackOptions';
|
||||||
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 { ScheduledTimeField } from './ScheduledTimeField';
|
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||||
|
|
||||||
export function RollbackScheduleDetailsFieldset() {
|
export function RollbackScheduleDetailsFieldset() {
|
||||||
const environmentsCount = useSelectedEnvironmentsCount();
|
|
||||||
const { isLoading } = useSelectEnvironmentsOnMount();
|
|
||||||
|
|
||||||
const groupNames = useGroupNames();
|
|
||||||
|
|
||||||
if (isLoading || !groupNames) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
{environmentsCount > 0 ? (
|
<RollbackOptions />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScheduledTimeField />
|
<ScheduledTimeField />
|
||||||
</div>
|
</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 { number } from 'yup';
|
||||||
|
|
||||||
import { NavTabs } from '@@/NavTabs';
|
import { NavTabs } from '@@/NavTabs';
|
||||||
|
@ -10,7 +10,7 @@ import { UpdateScheduleDetailsFieldset } from './UpdateScheduleDetailsFieldset';
|
||||||
import { RollbackScheduleDetailsFieldset } from './RollbackScheduleDetailsFieldset';
|
import { RollbackScheduleDetailsFieldset } from './RollbackScheduleDetailsFieldset';
|
||||||
|
|
||||||
export function ScheduleTypeSelector() {
|
export function ScheduleTypeSelector() {
|
||||||
const [{ value }, , { setValue }] = useField<FormValues['type']>('type');
|
const { values, setFieldValue } = useFormikContext<FormValues>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
@ -28,12 +28,17 @@ export function ScheduleTypeSelector() {
|
||||||
children: <RollbackScheduleDetailsFieldset />,
|
children: <RollbackScheduleDetailsFieldset />,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
selectedId={value}
|
selectedId={values.type}
|
||||||
onSelect={(value) => setValue(value)}
|
onSelect={handleChangeType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function handleChangeType(scheduleType: ScheduleType) {
|
||||||
|
setFieldValue('type', scheduleType);
|
||||||
|
setFieldValue('version', '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function typeValidation() {
|
export function typeValidation() {
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
import { useField } from 'formik';
|
|
||||||
import DateTimePicker from 'react-datetime-picker';
|
import DateTimePicker from 'react-datetime-picker';
|
||||||
import { Calendar, X } from 'lucide-react';
|
import { Calendar, X } from 'lucide-react';
|
||||||
import { useMemo } from '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 { FormControl } from '@@/form-components/FormControl';
|
||||||
import { Input } from '@@/form-components/Input';
|
import { Input } from '@@/form-components/Input';
|
||||||
|
@ -16,27 +21,49 @@ interface Props {
|
||||||
|
|
||||||
export function ScheduledTimeField({ disabled }: Props) {
|
export function ScheduledTimeField({ disabled }: Props) {
|
||||||
const [{ name, value }, { error }, { setValue }] =
|
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 (
|
return (
|
||||||
<FormControl label="Schedule date & time" errors={error}>
|
<FormControl label="Schedule date & time" errors={error}>
|
||||||
{!disabled ? (
|
{!disabled ? (
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
format="y-MM-dd HH:mm:ss"
|
format="y-MM-dd HH:mm:ss"
|
||||||
minDate={new Date()}
|
|
||||||
className="form-control [&>div]:border-0"
|
className="form-control [&>div]:border-0"
|
||||||
onChange={(date) => setValue(Math.floor(date.getTime() / 1000))}
|
onChange={(date) => setValue(isoDate(date.valueOf()))}
|
||||||
name={name}
|
name={name}
|
||||||
value={dateValue}
|
value={dateValue}
|
||||||
calendarIcon={<Calendar className="lucide" />}
|
calendarIcon={<Calendar className="lucide" />}
|
||||||
clearIcon={<X className="lucide" />}
|
clearIcon={<X className="lucide" />}
|
||||||
disableClock
|
disableClock
|
||||||
|
minDate={new Date(Date.now() - 24 * 60 * 60 * 1000)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input defaultValue={isoDateFromTimestamp(value)} disabled />
|
<Input defaultValue={value} disabled />
|
||||||
)}
|
)}
|
||||||
</FormControl>
|
</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 { 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 { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
|
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 { FormValues } from './types';
|
||||||
import { EnvironmentSelection } from './EnvironmentSelection';
|
|
||||||
import { ActiveSchedulesNotice } from './ActiveSchedulesNotice';
|
|
||||||
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
|
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
|
||||||
|
import { VersionSelect } from './VersionSelect';
|
||||||
|
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||||
|
|
||||||
export function UpdateScheduleDetailsFieldset() {
|
export function UpdateScheduleDetailsFieldset() {
|
||||||
const { values } = useFormikContext<FormValues>();
|
const { values } = useFormikContext<FormValues>();
|
||||||
|
|
||||||
const edgeGroupsEnvironmentIds = useEdgeGroupsEnvironmentIds(values.groupIds);
|
const environmentIdsQuery = useEdgeGroupsEnvironmentIds(values.groupIds);
|
||||||
|
|
||||||
|
const edgeGroupsEnvironmentIds = environmentIdsQuery.data || [];
|
||||||
const environments = useEnvironments(edgeGroupsEnvironmentIds);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<ActiveSchedulesNotice
|
{edgeGroupsEnvironmentIds.length > 0 ? (
|
||||||
selectedEdgeGroupIds={values.groupIds}
|
!!values.version && (
|
||||||
activeSchedules={activeSchedules}
|
<TextTip color="blue">
|
||||||
environments={environments}
|
{edgeGroupsEnvironmentIds.length} environment(s) will be updated to{' '}
|
||||||
/>
|
{values.version}
|
||||||
|
</TextTip>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<TextTip color="orange">
|
||||||
|
No environments options for the selected edge groups
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
|
||||||
<EnvironmentSelection
|
<VersionSelect minVersion={minVersion} />
|
||||||
activeSchedules={activeSchedules}
|
|
||||||
environments={environments}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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;
|
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 { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
|
|
||||||
import { ScheduleType } from '../types';
|
import { ScheduleType } from '../types';
|
||||||
|
@ -7,6 +6,6 @@ export interface FormValues {
|
||||||
name: string;
|
name: string;
|
||||||
groupIds: EdgeGroup['Id'][];
|
groupIds: EdgeGroup['Id'][];
|
||||||
type: ScheduleType;
|
type: ScheduleType;
|
||||||
time: number;
|
version: string;
|
||||||
environments: Record<EnvironmentId, string>;
|
scheduledTime: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ export function useEdgeGroupsEnvironmentIds(
|
||||||
Object.fromEntries(groups.map((g) => [g.Id, g.Endpoints])),
|
Object.fromEntries(groups.map((g) => [g.Id, g.Endpoints])),
|
||||||
});
|
});
|
||||||
|
|
||||||
return useMemo(
|
const envIds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
_.uniq(
|
_.uniq(
|
||||||
_.compact(
|
_.compact(
|
||||||
|
@ -23,4 +23,12 @@ export function useEdgeGroupsEnvironmentIds(
|
||||||
),
|
),
|
||||||
[edgeGroupsIds, groupsQuery.data]
|
[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 { nameValidation } from './NameField';
|
||||||
import { typeValidation } from './ScheduleTypeSelector';
|
import { typeValidation } from './ScheduleTypeSelector';
|
||||||
|
@ -13,9 +13,15 @@ export function validation(
|
||||||
groupIds: array().min(1, 'At least one group is required'),
|
groupIds: array().min(1, 'At least one group is required'),
|
||||||
name: nameValidation(schedules, currentId),
|
name: nameValidation(schedules, currentId),
|
||||||
type: typeValidation(),
|
type: typeValidation(),
|
||||||
time: number()
|
// time: number()
|
||||||
.min(Date.now() / 1000)
|
// .min(Date.now() / 1000)
|
||||||
.required(),
|
// .required(),
|
||||||
environments: object().default({}),
|
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() {
|
export function useCreateMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation(create, {
|
return useMutation(create, {
|
||||||
...withInvalidate(queryClient, [queryKeys.list()]),
|
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||||
...withError(),
|
...withError(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,14 +2,21 @@ import { useQuery } from 'react-query';
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
import { EdgeUpdateSchedule } from '../types';
|
import { EdgeUpdateResponse, StatusType } from '../types';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
import { queryKeys } from './query-keys';
|
||||||
import { buildUrl } from './urls';
|
import { buildUrl } from './urls';
|
||||||
|
|
||||||
async function getList() {
|
export type EdgeUpdateListItemResponse = EdgeUpdateResponse & {
|
||||||
|
status: StatusType;
|
||||||
|
statusMessage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getList(includeEdgeStacks?: boolean) {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<EdgeUpdateSchedule[]>(buildUrl());
|
const { data } = await axios.get<EdgeUpdateListItemResponse[]>(buildUrl(), {
|
||||||
|
params: { includeEdgeStacks },
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw parseAxiosError(
|
throw parseAxiosError(
|
||||||
|
@ -19,6 +26,8 @@ async function getList() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useList() {
|
export function useList(includeEdgeStacks?: boolean) {
|
||||||
return useQuery(queryKeys.list(), getList);
|
return useQuery(queryKeys.list(includeEdgeStacks), () =>
|
||||||
|
getList(includeEdgeStacks)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,13 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { EdgeUpdateSchedule } from '../types';
|
import { EdgeUpdateSchedule } from '../types';
|
||||||
|
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
list: () => ['edge', 'update_schedules'] as const,
|
base: () => ['edge', 'update_schedules'] as const,
|
||||||
item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.list(), id] as const,
|
list: (includeEdgeStacks?: boolean) =>
|
||||||
|
[...queryKeys.base(), { includeEdgeStacks }] as const,
|
||||||
|
item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.base(), id] as const,
|
||||||
activeSchedules: (environmentIds: EnvironmentId[]) =>
|
activeSchedules: (environmentIds: EnvironmentId[]) =>
|
||||||
[queryKeys.list(), 'active', { environmentIds }] as const,
|
[...queryKeys.base(), 'active', { environmentIds }] as const,
|
||||||
supportedAgentVersions: () => [queryKeys.list(), 'agent_versions'] as const,
|
supportedAgentVersions: () =>
|
||||||
previousVersions: () => [queryKeys.list(), 'previous_versions'] as const,
|
[...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 axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
import { EdgeUpdateSchedule } from '../types';
|
import { EdgeUpdateResponse, EdgeUpdateSchedule } from '../types';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
import { queryKeys } from './query-keys';
|
||||||
import { buildUrl } from './urls';
|
import { buildUrl } from './urls';
|
||||||
|
@ -11,9 +11,15 @@ export function useItem(id: EdgeUpdateSchedule['id']) {
|
||||||
return useQuery(queryKeys.item(id), () => getItem(id));
|
return useQuery(queryKeys.item(id), () => getItem(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EdgeUpdateItemResponse = EdgeUpdateResponse & {
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
async function getItem(id: EdgeUpdateSchedule['id']) {
|
async function getItem(id: EdgeUpdateSchedule['id']) {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<EdgeUpdateSchedule>(buildUrl(id));
|
const { data } = await axios.get<EdgeUpdateItemResponse>(buildUrl(id), {
|
||||||
|
params: { includeEdgeStack: true },
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw parseAxiosError(
|
throw parseAxiosError(
|
||||||
|
|
|
@ -6,11 +6,21 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { queryKeys } from './query-keys';
|
import { queryKeys } from './query-keys';
|
||||||
import { buildUrl } from './urls';
|
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>>({
|
export function usePreviousVersions<T = Record<EnvironmentId, string>>({
|
||||||
select,
|
select,
|
||||||
}: { select?: (data: Record<EnvironmentId, string>) => T } = {}) {
|
onSuccess,
|
||||||
|
enabled,
|
||||||
|
}: Options<T> = {}) {
|
||||||
return useQuery(queryKeys.previousVersions(), getPreviousVersions, {
|
return useQuery(queryKeys.previousVersions(), getPreviousVersions, {
|
||||||
select,
|
select,
|
||||||
|
onSuccess,
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ export function useRemoveMutation() {
|
||||||
),
|
),
|
||||||
|
|
||||||
mutationOptions(
|
mutationOptions(
|
||||||
withInvalidate(queryClient, [queryKeys.list()]),
|
withInvalidate(queryClient, [queryKeys.base()]),
|
||||||
withError()
|
withError()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,17 +1,32 @@
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
|
import semverCompare from 'semver-compare';
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
import { queryKeys } from './query-keys';
|
||||||
import { buildUrl } from './urls';
|
import { buildUrl } from './urls';
|
||||||
|
|
||||||
export function useSupportedAgentVersions<T = string[]>({
|
export function useSupportedAgentVersions(
|
||||||
select,
|
minVersion?: string,
|
||||||
}: { select?: (data: string[]) => T } = {}) {
|
{ onSuccess }: { onSuccess?(data: string[]): void } = {}
|
||||||
|
) {
|
||||||
return useQuery(
|
return useQuery(
|
||||||
queryKeys.supportedAgentVersions(),
|
[...queryKeys.supportedAgentVersions(), { minVersion }],
|
||||||
getSupportedAgentVersions,
|
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 {
|
interface Update {
|
||||||
id: EdgeUpdateSchedule['id'];
|
id: EdgeUpdateSchedule['id'];
|
||||||
values: FormValues;
|
values: Partial<FormValues>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function update({ id, values }: Update) {
|
async function update({ id, values }: Update) {
|
||||||
|
@ -30,7 +30,7 @@ async function update({ id, values }: Update) {
|
||||||
export function useUpdateMutation() {
|
export function useUpdateMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation(update, {
|
return useMutation(update, {
|
||||||
...withInvalidate(queryClient, [queryKeys.list()]),
|
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||||
...withError(),
|
...withError(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,22 +11,23 @@ export enum StatusType {
|
||||||
Pending,
|
Pending,
|
||||||
Failed,
|
Failed,
|
||||||
Success,
|
Success,
|
||||||
}
|
Sent,
|
||||||
|
|
||||||
interface Status {
|
|
||||||
status: StatusType;
|
|
||||||
error: string;
|
|
||||||
targetVersion: string;
|
|
||||||
currentVersion: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EdgeUpdateSchedule = {
|
export type EdgeUpdateSchedule = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
time: number;
|
|
||||||
groupIds: EdgeGroup['Id'][];
|
|
||||||
type: ScheduleType;
|
type: ScheduleType;
|
||||||
status: { [key: EnvironmentId]: Status };
|
|
||||||
created: number;
|
created: number;
|
||||||
createdBy: UserId;
|
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';
|
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||||
|
|
||||||
export enum FeatureFlag {
|
export enum FeatureFlag {
|
||||||
EdgeRemoteUpdate = 'edgeRemoteUpdate',
|
|
||||||
BEUpgrade = 'beUpgrade',
|
BEUpgrade = 'beUpgrade',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,10 +9,7 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||||
import {
|
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||||
FeatureFlag,
|
|
||||||
useFeatureFlag,
|
|
||||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
|
||||||
|
|
||||||
import { SidebarItem } from './SidebarItem';
|
import { SidebarItem } from './SidebarItem';
|
||||||
import { SidebarSection } from './SidebarSection';
|
import { SidebarSection } from './SidebarSection';
|
||||||
|
@ -27,10 +24,6 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
||||||
select: (settings) => settings.TeamSync,
|
select: (settings) => settings.TeamSync,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEdgeRemoteUpgradeEnabledQuery = useFeatureFlag(
|
|
||||||
FeatureFlag.EdgeRemoteUpdate
|
|
||||||
);
|
|
||||||
|
|
||||||
const showUsersSection =
|
const showUsersSection =
|
||||||
!window.ddExtension && (isAdmin || (isTeamLeader && !teamSyncQuery.data));
|
!window.ddExtension && (isAdmin || (isTeamLeader && !teamSyncQuery.data));
|
||||||
|
|
||||||
|
@ -77,7 +70,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
||||||
label="Tags"
|
label="Tags"
|
||||||
data-cy="portainerSidebar-environmentTags"
|
data-cy="portainerSidebar-environmentTags"
|
||||||
/>
|
/>
|
||||||
{isEdgeRemoteUpgradeEnabledQuery.data && (
|
{isBE && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="portainer.endpoints.updateSchedules"
|
to="portainer.endpoints.updateSchedules"
|
||||||
label="Update & Rollback"
|
label="Update & Rollback"
|
||||||
|
@ -93,7 +86,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
||||||
data-cy="portainerSidebar-registries"
|
data-cy="portainerSidebar-registries"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{process.env.PORTAINER_EDITION !== 'CE' && (
|
{isBE && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="portainer.licenses"
|
to="portainer.licenses"
|
||||||
label="Licenses"
|
label="Licenses"
|
||||||
|
@ -136,7 +129,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
||||||
data-cy="portainerSidebar-authentication"
|
data-cy="portainerSidebar-authentication"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{process.env.PORTAINER_EDITION !== 'CE' && (
|
{isBE && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
to="portainer.settings.cloud"
|
to="portainer.settings.cloud"
|
||||||
label="Cloud"
|
label="Cloud"
|
||||||
|
|
Loading…
Reference in New Issue