refactor(edge/updates): sync changes from EE [EE-4288] (#7726)

pull/8019/head
Chaim Lev-Ari 2022-12-01 08:40:52 +02:00 committed by GitHub
parent 4fee359247
commit 82e9e2a895
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 1099 additions and 1892 deletions

View File

@ -32,6 +32,7 @@ import (
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/internal/ssl"
"github.com/portainer/portainer/api/jwt"
@ -587,6 +588,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
digitalSignatureService := initDigitalSignatureService()
edgeStacksService := edgestacks.NewService(dataStore)
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger)
if err != nil {
log.Fatal().Err(err).Msg("")
@ -738,6 +741,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
HTTPEnabled: sslDBSettings.HTTPEnabled,
AssetsPath: *flags.Assets,
DataStore: dataStore,
EdgeStacksService: edgeStacksService,
SwarmStackManager: swarmStackManager,
ComposeStackManager: composeStackManager,
KubernetesDeployer: kubernetesDeployer,

View File

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

View File

@ -8,7 +8,6 @@ import (
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/edgetypes"
portainer "github.com/portainer/portainer/api"
)
@ -29,7 +28,6 @@ type (
EdgeGroup() EdgeGroupService
EdgeJob() EdgeJobService
EdgeStack() EdgeStackService
EdgeUpdateSchedule() EdgeUpdateScheduleService
Endpoint() EndpointService
EndpointGroup() EndpointGroupService
EndpointRelation() EndpointRelationService
@ -85,17 +83,6 @@ type (
BucketName() string
}
EdgeUpdateScheduleService interface {
ActiveSchedule(environmentID portainer.EndpointID) *edgetypes.EndpointUpdateScheduleRelation
ActiveSchedules(environmentIDs []portainer.EndpointID) []edgetypes.EndpointUpdateScheduleRelation
List() ([]edgetypes.UpdateSchedule, error)
Item(ID edgetypes.UpdateScheduleID) (*edgetypes.UpdateSchedule, error)
Create(edgeUpdateSchedule *edgetypes.UpdateSchedule) error
Update(ID edgetypes.UpdateScheduleID, edgeUpdateSchedule *edgetypes.UpdateSchedule) error
Delete(ID edgetypes.UpdateScheduleID) error
BucketName() string
}
// EdgeStackService represents a service to manage Edge stacks
EdgeStackService interface {
EdgeStacks() ([]portainer.EdgeStack, error)

View File

@ -14,7 +14,6 @@ import (
"github.com/portainer/portainer/api/dataservices/edgegroup"
"github.com/portainer/portainer/api/dataservices/edgejob"
"github.com/portainer/portainer/api/dataservices/edgestack"
"github.com/portainer/portainer/api/dataservices/edgeupdateschedule"
"github.com/portainer/portainer/api/dataservices/endpoint"
"github.com/portainer/portainer/api/dataservices/endpointgroup"
"github.com/portainer/portainer/api/dataservices/endpointrelation"
@ -50,7 +49,6 @@ type Store struct {
DockerHubService *dockerhub.Service
EdgeGroupService *edgegroup.Service
EdgeJobService *edgejob.Service
EdgeUpdateScheduleService *edgeupdateschedule.Service
EdgeStackService *edgestack.Service
EndpointGroupService *endpointgroup.Service
EndpointService *endpoint.Service
@ -95,12 +93,6 @@ func (store *Store) initServices() error {
}
store.DockerHubService = dockerhubService
edgeUpdateScheduleService, err := edgeupdateschedule.NewService(store.connection)
if err != nil {
return err
}
store.EdgeUpdateScheduleService = edgeUpdateScheduleService
edgeStackService, err := edgestack.NewService(store.connection)
if err != nil {
return err
@ -263,11 +255,6 @@ func (store *Store) EdgeJob() dataservices.EdgeJobService {
return store.EdgeJobService
}
// EdgeUpdateSchedule gives access to the EdgeUpdateSchedule data management layer
func (store *Store) EdgeUpdateSchedule() dataservices.EdgeUpdateScheduleService {
return store.EdgeUpdateScheduleService
}
// EdgeStack gives access to the EdgeStack data management layer
func (store *Store) EdgeStack() dataservices.EdgeStackService {
return store.EdgeStackService

View File

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

View File

@ -3,20 +3,16 @@ package edgestacks
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/internal/edge"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
)
type InvalidPayloadError struct {
@ -47,8 +43,14 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
if err != nil {
return httperror.BadRequest("Invalid query parameter: method", err)
}
dryrun, _ := request.RetrieveBooleanQueryParameter(r, "dryrun", true)
edgeStack, err := handler.createSwarmStack(method, r)
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user details from authentication token", err)
}
edgeStack, err := handler.createSwarmStack(method, dryrun, tokenData.ID, r)
if err != nil {
var payloadError *InvalidPayloadError
switch {
@ -62,14 +64,15 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
return response.JSON(w, edgeStack)
}
func (handler *Handler) createSwarmStack(method string, r *http.Request) (*portainer.EdgeStack, error) {
func (handler *Handler) createSwarmStack(method string, dryrun bool, userID portainer.UserID, r *http.Request) (*portainer.EdgeStack, error) {
switch method {
case "string":
return handler.createSwarmStackFromFileContent(r)
return handler.createSwarmStackFromFileContent(r, dryrun)
case "repository":
return handler.createSwarmStackFromGitRepository(r)
return handler.createSwarmStackFromGitRepository(r, dryrun, userID)
case "file":
return handler.createSwarmStackFromFileUpload(r)
return handler.createSwarmStackFromFileUpload(r, dryrun)
}
return nil, errors.New("Invalid value for query parameter: method. Value must be one of: string, repository or file")
}
@ -82,10 +85,13 @@ type swarmStackFromFileContentPayload struct {
// List of identifiers of EdgeGroups
EdgeGroups []portainer.EdgeGroupID `example:"1"`
// Deployment type to deploy this stack
// Valid values are: 0 - 'compose', 1 - 'kubernetes'
// Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad'
// for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints)
// kubernetes deploytype is enabled only for kubernetes environments(endpoints)
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"`
// kubernetes deploy type is enabled only for kubernetes environments(endpoints)
// nomad deploy type is enabled only for nomad environments(endpoints)
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"`
// List of Registries to use for this stack
Registries []portainer.RegistryID
}
func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error {
@ -101,85 +107,69 @@ func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error
return nil
}
func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*portainer.EdgeStack, error) {
func (handler *Handler) createSwarmStackFromFileContent(r *http.Request, dryrun bool) (*portainer.EdgeStack, error) {
var payload swarmStackFromFileContentPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return nil, err
}
err = handler.validateUniqueName(payload.Name)
stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries)
if err != nil {
return nil, err
return nil, errors.Wrap(err, "failed to create Edge stack object")
}
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
stack := &portainer.EdgeStack{
ID: portainer.EdgeStackID(stackID),
Name: payload.Name,
DeploymentType: payload.DeploymentType,
CreationDate: time.Now().Unix(),
EdgeGroups: payload.EdgeGroups,
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
Version: 1,
if dryrun {
return stack, nil
}
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
if err != nil {
return nil, fmt.Errorf("unable to find environment relations in database: %w", err)
}
return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, []byte(payload.StackFileContent))
})
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
if err != nil {
return nil, fmt.Errorf("unable to persist environment relation in database: %w", err)
}
}
stackFolder := strconv.Itoa(int(stack.ID))
if stack.DeploymentType == portainer.EdgeStackDeploymentCompose {
stack.EntryPoint = filesystem.ComposeFileDefaultName
func (handler *Handler) storeFileContent(stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) {
if deploymentType == portainer.EdgeStackDeploymentCompose {
composePath = filesystem.ComposeFileDefaultName
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, composePath, fileContent)
if err != nil {
return nil, err
return "", "", "", err
}
stack.ProjectPath = projectPath
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
manifestPath, err = handler.convertAndStoreKubeManifestIfNeeded(stackFolder, projectPath, composePath, relatedEndpointIds)
if err != nil {
return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err)
return "", "", "", fmt.Errorf("Failed creating and storing kube manifest: %w", err)
}
} else {
hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
if err != nil {
return nil, fmt.Errorf("unable to check for existence of docker endpoint: %w", err)
}
return composePath, manifestPath, projectPath, nil
if hasDockerEndpoint {
return nil, fmt.Errorf("edge stack with docker endpoint cannot be deployed with kubernetes config")
}
stack.ManifestPath = filesystem.ManifestFileDefaultName
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
if err != nil {
return nil, err
}
stack.ProjectPath = projectPath
}
err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds)
hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
if err != nil {
return nil, fmt.Errorf("Unable to update endpoint relations: %w", err)
return "", "", "", fmt.Errorf("unable to check for existence of docker environment: %w", err)
}
err = handler.DataStore.EdgeStack().Create(stack.ID, stack)
if err != nil {
return nil, err
if hasDockerEndpoint {
return "", "", "", fmt.Errorf("edge stack with docker environment cannot be deployed with kubernetes or nomad config")
}
return stack, nil
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
manifestPath = filesystem.ManifestFileDefaultName
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, fileContent)
if err != nil {
return "", "", "", err
}
return "", manifestPath, projectPath, nil
}
return "", "", "", fmt.Errorf("invalid deployment type: %d", deploymentType)
}
type swarmStackFromGitRepositoryPayload struct {
@ -200,10 +190,13 @@ type swarmStackFromGitRepositoryPayload struct {
// List of identifiers of EdgeGroups
EdgeGroups []portainer.EdgeGroupID `example:"1"`
// Deployment type to deploy this stack
// Valid values are: 0 - 'compose', 1 - 'kubernetes'
// Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad'
// for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints)
// kubernetes deploytype is enabled only for kubernetes environments(endpoints)
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"`
// kubernetes deploy type is enabled only for kubernetes environments(endpoints)
// nomad deploy type is enabled only for nomad environments(endpoints)
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"`
// List of Registries to use for this stack
Registries []portainer.RegistryID
}
func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error {
@ -217,7 +210,12 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
return &InvalidPayloadError{msg: "Invalid repository credentials. Password must be specified when authentication is enabled"}
}
if govalidator.IsNull(payload.FilePathInRepository) {
payload.FilePathInRepository = filesystem.ComposeFileDefaultName
switch payload.DeploymentType {
case portainer.EdgeStackDeploymentCompose:
payload.FilePathInRepository = filesystem.ComposeFileDefaultName
case portainer.EdgeStackDeploymentKubernetes:
payload.FilePathInRepository = filesystem.ManifestFileDefaultName
}
}
if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 {
return &InvalidPayloadError{msg: "Edge Groups are mandatory for an Edge stack"}
@ -225,83 +223,50 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
return nil
}
func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*portainer.EdgeStack, error) {
func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) {
var payload swarmStackFromGitRepositoryPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return nil, err
}
err = handler.validateUniqueName(payload.Name)
stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries)
if err != nil {
return nil, err
return nil, errors.Wrap(err, "failed to create edge stack object")
}
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
stack := &portainer.EdgeStack{
ID: portainer.EdgeStackID(stackID),
Name: payload.Name,
CreationDate: time.Now().Unix(),
EdgeGroups: payload.EdgeGroups,
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
DeploymentType: payload.DeploymentType,
Version: 1,
if dryrun {
return stack, nil
}
projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID)))
stack.ProjectPath = projectPath
repositoryUsername := payload.RepositoryUsername
repositoryPassword := payload.RepositoryPassword
if !payload.RepositoryAuthentication {
repositoryUsername = ""
repositoryPassword = ""
repoConfig := gittypes.RepoConfig{
URL: payload.RepositoryURL,
ReferenceName: payload.RepositoryReferenceName,
}
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
if err != nil {
return nil, fmt.Errorf("failed fetching relations config: %w", err)
}
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
if err != nil {
return nil, fmt.Errorf("unable to retrieve related endpoints: %w", err)
}
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return nil, err
}
if stack.DeploymentType == portainer.EdgeStackDeploymentCompose {
stack.EntryPoint = payload.FilePathInRepository
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
if err != nil {
return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err)
if payload.RepositoryAuthentication {
repoConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
}
} else {
stack.ManifestPath = payload.FilePathInRepository
}
err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds)
if err != nil {
return nil, fmt.Errorf("Unable to update environment relations: %w", err)
}
err = handler.DataStore.EdgeStack().Create(stack.ID, stack)
if err != nil {
return nil, err
}
return stack, nil
return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
return handler.storeManifestFromGitRepository(stackFolder, relatedEndpointIds, payload.DeploymentType, userID, repoConfig)
})
}
type swarmStackFromFileUploadPayload struct {
Name string
StackFileContent []byte
EdgeGroups []portainer.EdgeGroupID
DeploymentType portainer.EdgeStackDeploymentType
// Deployment type to deploy this stack
// Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad'
// for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints)
// kubernetes deploytype is enabled only for kubernetes environments(endpoints)
// nomad deploytype is enabled only for nomad environments(endpoints)
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"`
Registries []portainer.RegistryID
}
func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error {
@ -330,109 +295,67 @@ func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error
}
payload.DeploymentType = portainer.EdgeStackDeploymentType(deploymentType)
var registries []portainer.RegistryID
request.RetrieveMultiPartFormJSONValue(r, "Registries", &registries, false)
if err != nil {
return errors.New("Invalid registry type")
}
payload.Registries = registries
return nil
}
func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portainer.EdgeStack, error) {
func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request, dryrun bool) (*portainer.EdgeStack, error) {
payload := &swarmStackFromFileUploadPayload{}
err := payload.Validate(r)
if err != nil {
return nil, err
}
err = handler.validateUniqueName(payload.Name)
stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries)
if err != nil {
return nil, err
return nil, errors.Wrap(err, "failed to create edge stack object")
}
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
stack := &portainer.EdgeStack{
ID: portainer.EdgeStackID(stackID),
Name: payload.Name,
DeploymentType: payload.DeploymentType,
CreationDate: time.Now().Unix(),
EdgeGroups: payload.EdgeGroups,
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
Version: 1,
if dryrun {
return stack, nil
}
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
if err != nil {
return nil, fmt.Errorf("failed fetching relations config: %w", err)
}
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
if err != nil {
return nil, fmt.Errorf("unable to retrieve related endpoints: %w", err)
}
stackFolder := strconv.Itoa(int(stack.ID))
if stack.DeploymentType == portainer.EdgeStackDeploymentCompose {
stack.EntryPoint = filesystem.ComposeFileDefaultName
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil {
return nil, err
}
stack.ProjectPath = projectPath
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
if err != nil {
return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err)
}
} else {
stack.ManifestPath = filesystem.ManifestFileDefaultName
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
if err != nil {
return nil, err
}
stack.ProjectPath = projectPath
}
err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds)
if err != nil {
return nil, fmt.Errorf("Unable to update environment relations: %w", err)
}
err = handler.DataStore.EdgeStack().Create(stack.ID, stack)
if err != nil {
return nil, err
}
return stack, nil
return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, payload.StackFileContent)
})
}
func (handler *Handler) validateUniqueName(name string) error {
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
func (handler *Handler) storeManifestFromGitRepository(stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) {
projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder)
repositoryUsername := ""
repositoryPassword := ""
if repositoryConfig.Authentication != nil {
if repositoryConfig.Authentication.Password != "" {
repositoryUsername = repositoryConfig.Authentication.Username
repositoryPassword = repositoryConfig.Authentication.Password
}
}
err = handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return err
return "", "", "", err
}
for _, stack := range edgeStacks {
if strings.EqualFold(stack.Name, name) {
return errors.New("Edge stack name must be unique")
}
}
return nil
}
if deploymentType == portainer.EdgeStackDeploymentCompose {
composePath := repositoryConfig.ConfigFilePath
// updateEndpointRelations adds a relation between the Edge Stack to the related environments(endpoints)
func updateEndpointRelations(endpointRelationService dataservices.EndpointRelationService, edgeStackID portainer.EdgeStackID, relatedEndpointIds []portainer.EndpointID) error {
for _, endpointID := range relatedEndpointIds {
relation, err := endpointRelationService.EndpointRelation(endpointID)
manifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, projectPath, composePath, relatedEndpointIds)
if err != nil {
return fmt.Errorf("unable to find environment relation in database: %w", err)
return "", "", "", fmt.Errorf("Failed creating and storing kube manifest: %w", err)
}
relation.EdgeStacks[edgeStackID] = true
err = endpointRelationService.UpdateEndpointRelation(endpointID, relation)
if err != nil {
return fmt.Errorf("unable to persist environment relation in database: %w", err)
}
return composePath, manifestPath, projectPath, nil
}
return nil
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
return "", repositoryConfig.ConfigFilePath, projectPath, nil
}
return "", "", "", fmt.Errorf("unknown deployment type: %d", deploymentType)
}

View File

@ -7,7 +7,6 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/edge"
)
// @id EdgeStackDelete
@ -35,33 +34,9 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", err)
}
err = handler.DataStore.EdgeStack().DeleteEdgeStack(portainer.EdgeStackID(edgeStackID))
err = handler.edgeStacksService.DeleteEdgeStack(edgeStack.ID, edgeStack.EdgeGroups)
if err != nil {
return httperror.InternalServerError("Unable to remove the edge stack from the database", err)
}
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
if err != nil {
return httperror.InternalServerError("Unable to find environment relations in database", err)
}
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
if err != nil {
return httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
}
for _, endpointID := range relatedEndpointIds {
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
return httperror.InternalServerError("Unable to find environment relation in database", err)
}
delete(relation.EdgeStacks, edgeStack.ID)
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
if err != nil {
return httperror.InternalServerError("Unable to persist environment relation in database", err)
}
return httperror.InternalServerError("Unable to delete edge stack", err)
}
return response.Empty(w)

View File

@ -14,19 +14,22 @@ import (
type updateStatusPayload struct {
Error string
Status *portainer.EdgeStackStatusType
EndpointID *portainer.EndpointID
EndpointID portainer.EndpointID
}
func (payload *updateStatusPayload) Validate(r *http.Request) error {
if payload.Status == nil {
return errors.New("Invalid status")
}
if payload.EndpointID == nil {
if payload.EndpointID == 0 {
return errors.New("Invalid EnvironmentID")
}
if *payload.Status == portainer.StatusError && govalidator.IsNull(payload.Error) {
return errors.New("Error message is mandatory when status is error")
}
return nil
}
@ -62,7 +65,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return httperror.BadRequest("Invalid request payload", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(*payload.EndpointID))
endpoint, err := handler.DataStore.Endpoint().Endpoint(payload.EndpointID)
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
} else if err != nil {
@ -74,10 +77,10 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
return httperror.Forbidden("Permission denied to access environment", err)
}
stack.Status[*payload.EndpointID] = portainer.EdgeStackStatus{
stack.Status[payload.EndpointID] = portainer.EdgeStackStatus{
Type: *payload.Status,
Error: payload.Error,
EndpointID: *payload.EndpointID,
EndpointID: payload.EndpointID,
}
err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack)

View File

@ -17,6 +17,7 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/portainer/portainer/api/jwt"
"github.com/pkg/errors"
@ -69,9 +70,12 @@ func setupHandler(t *testing.T) (*Handler, string, func()) {
t.Fatal(err)
}
edgeStacksService := edgestacks.NewService(store)
handler := NewHandler(
security.NewRequestBouncer(store, jwtService, apiKeyService),
store,
edgeStacksService,
)
tmpDir := t.TempDir()
@ -779,7 +783,7 @@ func TestUpdateStatusAndInspect(t *testing.T) {
payload := updateStatusPayload{
Error: "test-error",
Status: &newStatus,
EndpointID: &endpoint.ID,
EndpointID: endpoint.ID,
}
jsonPayload, err := json.Marshal(payload)
@ -829,7 +833,7 @@ func TestUpdateStatusAndInspect(t *testing.T) {
t.Fatalf(fmt.Sprintf("expected EdgeStackStatusError %s, found %s", payload.Error, data.Status[endpoint.ID].Error))
}
if data.Status[endpoint.ID].EndpointID != *payload.EndpointID {
if data.Status[endpoint.ID].EndpointID != payload.EndpointID {
t.Fatalf(fmt.Sprintf("expected EndpointID %d, found %d", payload.EndpointID, data.Status[endpoint.ID].EndpointID))
}
}
@ -854,7 +858,7 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) {
updateStatusPayload{
Error: "test-error",
Status: nil,
EndpointID: &endpoint.ID,
EndpointID: endpoint.ID,
},
"Invalid status",
400,
@ -864,17 +868,17 @@ func TestUpdateStatusWithInvalidPayload(t *testing.T) {
updateStatusPayload{
Error: "",
Status: &statusError,
EndpointID: &endpoint.ID,
EndpointID: endpoint.ID,
},
"Error message is mandatory when status is error",
400,
},
{
"Update with nil EndpointID",
"Update with missing EndpointID",
updateStatusPayload{
Error: "",
Status: &statusOk,
EndpointID: nil,
EndpointID: 0,
},
"Invalid EnvironmentID",
400,

View File

@ -65,18 +65,20 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid request payload", err)
}
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
relationConfig, err := edge.FetchEndpointRelationsConfig(handler.DataStore)
if err != nil {
return httperror.InternalServerError("Unable to retrieve environments relations config from database", err)
}
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
if err != nil {
return httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
}
endpointsToAdd := map[portainer.EndpointID]bool{}
if payload.EdgeGroups != nil {
newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
if err != nil {
return httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
}
@ -105,7 +107,6 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
}
}
endpointsToAdd := map[portainer.EndpointID]bool{}
for endpointID := range newRelatedSet {
if !oldRelatedSet[endpointID] {
endpointsToAdd[endpointID] = true
@ -143,22 +144,26 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
}
stackFolder := strconv.Itoa(int(stack.ID))
if payload.DeploymentType == portainer.EdgeStackDeploymentCompose {
if stack.EntryPoint == "" {
stack.EntryPoint = filesystem.ComposeFileDefaultName
}
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
_, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil {
return httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
}
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
manifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, stack.ProjectPath, stack.EntryPoint, relatedEndpointIds)
if err != nil {
return httperror.InternalServerError("Unable to convert and persist updated Kubernetes manifest file on disk", err)
}
} else {
stack.ManifestPath = manifestPath
}
if payload.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
if stack.ManifestPath == "" {
stack.ManifestPath = filesystem.ManifestFileDefaultName
}
@ -174,11 +179,12 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
if err != nil {
return httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
return httperror.InternalServerError("Unable to persist updated Kubernetes manifest file on disk", err)
}
}
if payload.Version != nil && *payload.Version != stack.Version {
versionUpdated := payload.Version != nil && *payload.Version != stack.Version
if versionUpdated {
stack.Version = *payload.Version
stack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{}
}

View File

@ -30,32 +30,3 @@ func hasEndpointPredicate(endpointService dataservices.EndpointService, endpoint
return false, nil
}
type endpointRelationsConfig struct {
endpoints []portainer.Endpoint
endpointGroups []portainer.EndpointGroup
edgeGroups []portainer.EdgeGroup
}
func fetchEndpointRelationsConfig(dataStore dataservices.DataStore) (*endpointRelationsConfig, error) {
endpoints, err := dataStore.Endpoint().Endpoints()
if err != nil {
return nil, fmt.Errorf("unable to retrieve environments from database: %w", err)
}
endpointGroups, err := dataStore.EndpointGroup().EndpointGroups()
if err != nil {
return nil, fmt.Errorf("unable to retrieve environment groups from database: %w", err)
}
edgeGroups, err := dataStore.EdgeGroup().EdgeGroups()
if err != nil {
return nil, fmt.Errorf("unable to retrieve edge groups from database: %w", err)
}
return &endpointRelationsConfig{
endpoints: endpoints,
endpointGroups: endpointGroups,
edgeGroups: edgeGroups,
}, nil
}

View File

@ -3,7 +3,6 @@ package edgestacks
import (
"fmt"
"net/http"
"strconv"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
@ -12,6 +11,7 @@ import (
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
)
// Handler is the HTTP handler used to handle environment(endpoint) group operations.
@ -21,16 +21,19 @@ type Handler struct {
DataStore dataservices.DataStore
FileService portainer.FileService
GitService portainer.GitService
edgeStacksService *edgestackservice.Service
KubernetesDeployer portainer.KubernetesDeployer
}
// NewHandler creates a handler to manage environment(endpoint) group operations.
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) *Handler {
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
DataStore: dataStore,
Router: mux.NewRouter(),
requestBouncer: bouncer,
DataStore: dataStore,
edgeStacksService: edgeStacksService,
}
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
h.Handle("/edge_stacks",
@ -54,33 +57,31 @@ func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataSto
return h
}
func (handler *Handler) convertAndStoreKubeManifestIfNeeded(edgeStack *portainer.EdgeStack, relatedEndpointIds []portainer.EndpointID) error {
func (handler *Handler) convertAndStoreKubeManifestIfNeeded(stackFolder string, projectPath, composePath string, relatedEndpointIds []portainer.EndpointID) (manifestPath string, err error) {
hasKubeEndpoint, err := hasKubeEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
if err != nil {
return fmt.Errorf("unable to check if edge stack has kube environments: %w", err)
return "", fmt.Errorf("unable to check if edge stack has kube environments: %w", err)
}
if !hasKubeEndpoint {
return nil
return "", nil
}
composeConfig, err := handler.FileService.GetFileContent(edgeStack.ProjectPath, edgeStack.EntryPoint)
composeConfig, err := handler.FileService.GetFileContent(projectPath, composePath)
if err != nil {
return fmt.Errorf("unable to retrieve Compose file from disk: %w", err)
return "", fmt.Errorf("unable to retrieve Compose file from disk: %w", err)
}
kompose, err := handler.KubernetesDeployer.ConvertCompose(composeConfig)
if err != nil {
return fmt.Errorf("failed converting compose file to kubernetes manifest: %w", err)
return "", fmt.Errorf("failed converting compose file to kubernetes manifest: %w", err)
}
komposeFileName := filesystem.ManifestFileDefaultName
_, err = handler.FileService.StoreEdgeStackFileFromBytes(strconv.Itoa(int(edgeStack.ID)), komposeFileName, kompose)
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, komposeFileName, kompose)
if err != nil {
return fmt.Errorf("failed to store kube manifest file: %w", err)
return "", fmt.Errorf("failed to store kube manifest file: %w", err)
}
edgeStack.ManifestPath = komposeFileName
return nil
return komposeFileName, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,6 @@ import (
"github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks"
"github.com/portainer/portainer/api/http/handler/edgetemplates"
"github.com/portainer/portainer/api/http/handler/edgeupdateschedules"
"github.com/portainer/portainer/api/http/handler/endpointedge"
"github.com/portainer/portainer/api/http/handler/endpointgroups"
"github.com/portainer/portainer/api/http/handler/endpointproxy"
@ -44,43 +43,42 @@ import (
// Handler is a collection of all the service handlers.
type Handler struct {
AuthHandler *auth.Handler
BackupHandler *backup.Handler
CustomTemplatesHandler *customtemplates.Handler
DockerHandler *docker.Handler
EdgeGroupsHandler *edgegroups.Handler
EdgeJobsHandler *edgejobs.Handler
EdgeUpdateScheduleHandler *edgeupdateschedules.Handler
EdgeStacksHandler *edgestacks.Handler
EdgeTemplatesHandler *edgetemplates.Handler
EndpointEdgeHandler *endpointedge.Handler
EndpointGroupHandler *endpointgroups.Handler
EndpointHandler *endpoints.Handler
EndpointHelmHandler *helm.Handler
EndpointProxyHandler *endpointproxy.Handler
HelmTemplatesHandler *helm.Handler
KubernetesHandler *kubernetes.Handler
FileHandler *file.Handler
LDAPHandler *ldap.Handler
MOTDHandler *motd.Handler
RegistryHandler *registries.Handler
ResourceControlHandler *resourcecontrols.Handler
RoleHandler *roles.Handler
SettingsHandler *settings.Handler
SSLHandler *ssl.Handler
OpenAMTHandler *openamt.Handler
FDOHandler *fdo.Handler
StackHandler *stacks.Handler
StatusHandler *status.Handler
StorybookHandler *storybook.Handler
TagHandler *tags.Handler
TeamMembershipHandler *teammemberships.Handler
TeamHandler *teams.Handler
TemplatesHandler *templates.Handler
UploadHandler *upload.Handler
UserHandler *users.Handler
WebSocketHandler *websocket.Handler
WebhookHandler *webhooks.Handler
AuthHandler *auth.Handler
BackupHandler *backup.Handler
CustomTemplatesHandler *customtemplates.Handler
DockerHandler *docker.Handler
EdgeGroupsHandler *edgegroups.Handler
EdgeJobsHandler *edgejobs.Handler
EdgeStacksHandler *edgestacks.Handler
EdgeTemplatesHandler *edgetemplates.Handler
EndpointEdgeHandler *endpointedge.Handler
EndpointGroupHandler *endpointgroups.Handler
EndpointHandler *endpoints.Handler
EndpointHelmHandler *helm.Handler
EndpointProxyHandler *endpointproxy.Handler
HelmTemplatesHandler *helm.Handler
KubernetesHandler *kubernetes.Handler
FileHandler *file.Handler
LDAPHandler *ldap.Handler
MOTDHandler *motd.Handler
RegistryHandler *registries.Handler
ResourceControlHandler *resourcecontrols.Handler
RoleHandler *roles.Handler
SettingsHandler *settings.Handler
SSLHandler *ssl.Handler
OpenAMTHandler *openamt.Handler
FDOHandler *fdo.Handler
StackHandler *stacks.Handler
StatusHandler *status.Handler
StorybookHandler *storybook.Handler
TagHandler *tags.Handler
TeamMembershipHandler *teammemberships.Handler
TeamHandler *teams.Handler
TemplatesHandler *templates.Handler
UploadHandler *upload.Handler
UserHandler *users.Handler
WebSocketHandler *websocket.Handler
WebhookHandler *webhooks.Handler
}
// @title PortainerCE API
@ -169,8 +167,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/custom_templates"):
http.StripPrefix("/api", h.CustomTemplatesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/edge_update_schedules"):
http.StripPrefix("/api", h.EdgeUpdateScheduleHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"):
http.StripPrefix("/api", h.EdgeStacksHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/edge_groups"):

View File

@ -23,7 +23,6 @@ import (
"github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks"
"github.com/portainer/portainer/api/http/handler/edgetemplates"
"github.com/portainer/portainer/api/http/handler/edgeupdateschedules"
"github.com/portainer/portainer/api/http/handler/endpointedge"
"github.com/portainer/portainer/api/http/handler/endpointgroups"
"github.com/portainer/portainer/api/http/handler/endpointproxy"
@ -56,6 +55,7 @@ import (
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/portainer/portainer/api/internal/ssl"
k8s "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
@ -77,6 +77,7 @@ type Server struct {
ReverseTunnelService portainer.ReverseTunnelService
ComposeStackManager portainer.ComposeStackManager
CryptoService portainer.CryptoService
EdgeStacksService *edgestackservice.Service
SignatureService portainer.DigitalSignatureService
SnapshotService portainer.SnapshotService
FileService portainer.FileService
@ -153,9 +154,7 @@ func (server *Server) Start() error {
edgeJobsHandler.FileService = server.FileService
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
edgeUpdateScheduleHandler := edgeupdateschedules.NewHandler(requestBouncer, server.DataStore)
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore)
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer, server.DataStore, server.EdgeStacksService)
edgeStacksHandler.FileService = server.FileService
edgeStacksHandler.GitService = server.GitService
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
@ -277,43 +276,42 @@ func (server *Server) Start() error {
webhookHandler.DockerClientFactory = server.DockerClientFactory
server.Handler = &handler.Handler{
RoleHandler: roleHandler,
AuthHandler: authHandler,
BackupHandler: backupHandler,
CustomTemplatesHandler: customTemplatesHandler,
DockerHandler: dockerHandler,
EdgeGroupsHandler: edgeGroupsHandler,
EdgeJobsHandler: edgeJobsHandler,
EdgeUpdateScheduleHandler: edgeUpdateScheduleHandler,
EdgeStacksHandler: edgeStacksHandler,
EdgeTemplatesHandler: edgeTemplatesHandler,
EndpointGroupHandler: endpointGroupHandler,
EndpointHandler: endpointHandler,
EndpointHelmHandler: endpointHelmHandler,
EndpointEdgeHandler: endpointEdgeHandler,
EndpointProxyHandler: endpointProxyHandler,
FileHandler: fileHandler,
LDAPHandler: ldapHandler,
HelmTemplatesHandler: helmTemplatesHandler,
KubernetesHandler: kubernetesHandler,
MOTDHandler: motdHandler,
OpenAMTHandler: openAMTHandler,
FDOHandler: fdoHandler,
RegistryHandler: registryHandler,
ResourceControlHandler: resourceControlHandler,
SettingsHandler: settingsHandler,
SSLHandler: sslHandler,
StatusHandler: statusHandler,
StackHandler: stackHandler,
StorybookHandler: storybookHandler,
TagHandler: tagHandler,
TeamHandler: teamHandler,
TeamMembershipHandler: teamMembershipHandler,
TemplatesHandler: templatesHandler,
UploadHandler: uploadHandler,
UserHandler: userHandler,
WebSocketHandler: websocketHandler,
WebhookHandler: webhookHandler,
RoleHandler: roleHandler,
AuthHandler: authHandler,
BackupHandler: backupHandler,
CustomTemplatesHandler: customTemplatesHandler,
DockerHandler: dockerHandler,
EdgeGroupsHandler: edgeGroupsHandler,
EdgeJobsHandler: edgeJobsHandler,
EdgeStacksHandler: edgeStacksHandler,
EdgeTemplatesHandler: edgeTemplatesHandler,
EndpointGroupHandler: endpointGroupHandler,
EndpointHandler: endpointHandler,
EndpointHelmHandler: endpointHelmHandler,
EndpointEdgeHandler: endpointEdgeHandler,
EndpointProxyHandler: endpointProxyHandler,
FileHandler: fileHandler,
LDAPHandler: ldapHandler,
HelmTemplatesHandler: helmTemplatesHandler,
KubernetesHandler: kubernetesHandler,
MOTDHandler: motdHandler,
OpenAMTHandler: openAMTHandler,
FDOHandler: fdoHandler,
RegistryHandler: registryHandler,
ResourceControlHandler: resourceControlHandler,
SettingsHandler: settingsHandler,
SSLHandler: sslHandler,
StatusHandler: statusHandler,
StackHandler: stackHandler,
StorybookHandler: storybookHandler,
TagHandler: tagHandler,
TeamHandler: teamHandler,
TeamMembershipHandler: teamMembershipHandler,
TemplatesHandler: templatesHandler,
UploadHandler: uploadHandler,
UserHandler: userHandler,
WebSocketHandler: websocketHandler,
WebhookHandler: webhookHandler,
}
handler := adminMonitor.WithRedirect(offlineGate.WaitingMiddleware(time.Minute, server.Handler))

View File

@ -2,8 +2,10 @@ package edge
import (
"errors"
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
// EdgeStackRelatedEndpoints returns a list of environments(endpoints) related to this Edge stack
@ -29,3 +31,33 @@ func EdgeStackRelatedEndpoints(edgeGroupIDs []portainer.EdgeGroupID, endpoints [
return edgeStackEndpoints, nil
}
type EndpointRelationsConfig struct {
Endpoints []portainer.Endpoint
EndpointGroups []portainer.EndpointGroup
EdgeGroups []portainer.EdgeGroup
}
// FetchEndpointRelationsConfig fetches config needed for Edge Stack related endpoints
func FetchEndpointRelationsConfig(dataStore dataservices.DataStore) (*EndpointRelationsConfig, error) {
endpoints, err := dataStore.Endpoint().Endpoints()
if err != nil {
return nil, fmt.Errorf("unable to retrieve environments from database: %w", err)
}
endpointGroups, err := dataStore.EndpointGroup().EndpointGroups()
if err != nil {
return nil, fmt.Errorf("unable to retrieve environment groups from database: %w", err)
}
edgeGroups, err := dataStore.EdgeGroup().EdgeGroups()
if err != nil {
return nil, fmt.Errorf("unable to retrieve edge groups from database: %w", err)
}
return &EndpointRelationsConfig{
Endpoints: endpoints,
EndpointGroups: endpointGroups,
EdgeGroups: edgeGroups,
}, nil
}

View File

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

View File

@ -23,7 +23,9 @@ func Test_updateEndpointRelation_successfulRuns(t *testing.T) {
dataStore := testhelpers.NewDatastore(testhelpers.WithEndpointRelations(endpointRelations))
err := updateEndpointRelations(dataStore.EndpointRelation(), edgeStackID, relatedIds)
service := NewService(dataStore)
err := service.updateEndpointRelations(edgeStackID, relatedIds)
assert.NoError(t, err, "updateEndpointRelations should not fail")

View File

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

View File

@ -12,7 +12,6 @@ type testDatastore struct {
customTemplate dataservices.CustomTemplateService
edgeGroup dataservices.EdgeGroupService
edgeJob dataservices.EdgeJobService
edgeUpdateSchedule dataservices.EdgeUpdateScheduleService
edgeStack dataservices.EdgeStackService
endpoint dataservices.EndpointService
endpointGroup dataservices.EndpointGroupService
@ -49,9 +48,7 @@ func (d *testDatastore) EdgeJob() dataservices.EdgeJobService { re
func (d *testDatastore) EdgeStack() dataservices.EdgeStackService { return d.edgeStack }
func (d *testDatastore) Endpoint() dataservices.EndpointService { return d.endpoint }
func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { return d.endpointGroup }
func (d *testDatastore) EdgeUpdateSchedule() dataservices.EdgeUpdateScheduleService {
return d.edgeUpdateSchedule
}
func (d *testDatastore) FDOProfile() dataservices.FDOProfileService {
return d.fdoProfile
}

View File

@ -1500,12 +1500,10 @@ const (
WebSocketKeepAlive = 1 * time.Hour
)
const FeatureFlagEdgeRemoteUpdate Feature = "edgeRemoteUpdate"
const FeatureFlagBEUpgrade = "beUpgrade"
// List of supported features
var SupportedFeatureFlags = []Feature{
FeatureFlagEdgeRemoteUpdate,
FeatureFlagBEUpgrade,
}

View File

@ -37,13 +37,18 @@ export function humanize(bytes, round, base) {
return filesize(bytes, { base: base, round: round });
}
}
export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
export function isoDateFromTimestamp(timestamp) {
return moment.unix(timestamp).format('YYYY-MM-DD HH:mm:ss');
return moment.unix(timestamp).format(TIME_FORMAT);
}
export function isoDate(date) {
return moment(date).format('YYYY-MM-DD HH:mm:ss');
return moment(date).format(TIME_FORMAT);
}
export function parseIsoDate(date) {
return moment(date, TIME_FORMAT).toDate();
}
export function getPairKey(pair, separator) {

View File

@ -1,6 +1,5 @@
import { PropsWithChildren, AnchorHTMLAttributes } from 'react';
import { UISref, UISrefProps } from '@uirouter/react';
import clsx from 'clsx';
interface Props {
title?: string;
@ -15,7 +14,7 @@ export function Link({
}: PropsWithChildren<Props> & UISrefProps) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<UISref className={clsx('no-decoration', className)} {...props}>
<UISref className={className} {...props}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a title={title} target={props.target}>
{children}

View File

@ -61,9 +61,7 @@ export function Button({
// eslint-disable-next-line react/jsx-props-no-spreading
{...ariaProps}
>
{icon && (
<Icon icon={icon} size={getIconSize(size)} className="inline-flex" />
)}
{icon && <Icon icon={icon} size={getIconSize(size)} />}
{children}
</button>
);

View File

@ -1,7 +1,6 @@
import { useRouter } from '@uirouter/react';
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
import { EdgeTypes } from '@/react/portainer/environments/types';
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
import { InformationPanel } from '@@/InformationPanel';
import { TextTip } from '@@/Tip/TextTip';
@ -9,8 +8,9 @@ import { PageHeader } from '@@/PageHeader';
import { Datatable } from './Datatable';
export function WaitingRoomView() {
const router = useRouter();
export default withLimitToBE(WaitingRoomView);
function WaitingRoomView() {
const { environments, isLoading, totalCount } = useEnvironmentList({
edgeDevice: true,
edgeDeviceUntrusted: true,
@ -18,11 +18,6 @@ export function WaitingRoomView() {
types: EdgeTypes,
});
if (process.env.PORTAINER_EDITION !== 'BE') {
router.stateService.go('edge.devices');
return null;
}
return (
<>
<PageHeader

View File

@ -1 +1 @@
export { WaitingRoomView } from './WaitingRoomView';
export { default as WaitingRoomView } from './WaitingRoomView';

View File

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

View File

@ -81,7 +81,10 @@ export function IngressDatatable() {
</Authorized>
<Authorized authorizations="K8sIngressesW">
<Link to="kubernetes.ingresses.create" className="space-left">
<Link
to="kubernetes.ingresses.create"
className="space-left no-decoration"
>
<Button
icon={Plus}
className="btn-wrapper vertical-center"
@ -92,7 +95,7 @@ export function IngressDatatable() {
</Link>
</Authorized>
<Authorized authorizations="K8sIngressesW">
<Link to="kubernetes.deploy" className="space-left">
<Link to="kubernetes.deploy" className="space-left no-decoration">
<Button icon={Plus} className="btn-wrapper">
Create from manifest
</Button>

View File

@ -40,6 +40,7 @@ import { EnvironmentItem } from './EnvironmentItem';
import { KubeconfigButton } from './KubeconfigButton';
import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel';
import styles from './EnvironmentList.module.css';
import { UpdateBadge } from './UpdateBadge';
interface Props {
onClickItem(environment: Environment): void;
@ -131,21 +132,27 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
edgeDevice: false,
tagsPartialMatch: true,
agentVersions: agentVersions.map((a) => a.value),
updateInformation: isBE,
};
const tagsQuery = useTags();
const { isLoading, environments, totalCount, totalAvailable } =
useEnvironmentList(
{
page,
pageLimit,
sort: sortByFilter,
order: sortByDescending ? 'desc' : 'asc',
...environmentsQueryParams,
},
refetchIfAnyOffline
);
const {
isLoading,
environments,
totalCount,
totalAvailable,
updateAvailable,
} = useEnvironmentList(
{
page,
pageLimit,
sort: sortByFilter,
order: sortByDescending ? 'desc' : 'asc',
...environmentsQueryParams,
},
refetchIfAnyOffline
);
const agentVersionsQuery = useAgentVersionsList();
@ -175,9 +182,10 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
return (
<>
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
<TableContainer>
<TableTitle icon={HardDrive} label="Environments" />
<TableTitle icon={HardDrive} label="Environments">
{isBE && updateAvailable && <UpdateBadge />}
</TableTitle>
<TableActions className={styles.actionBar}>
<div className={styles.description}>

View File

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

View File

@ -28,6 +28,7 @@ export interface EnvironmentsQueryParams {
provisioned?: boolean;
name?: string;
agentVersions?: string[];
updateInformation?: boolean;
}
export interface GetEnvironmentsOptions {
@ -46,7 +47,12 @@ export async function getEnvironments(
}: GetEnvironmentsOptions = { query: {} }
) {
if (query.tagIds && query.tagIds.length === 0) {
return { totalCount: 0, value: <Environment[]>[] };
return {
totalCount: 0,
value: <Environment[]>[],
totalAvailable: 0,
updateAvailable: false,
};
}
const url = buildUrl();
@ -63,11 +69,13 @@ export async function getEnvironments(
const response = await axios.get<Environment[]>(url, { params });
const totalCount = response.headers['x-total-count'];
const totalAvailable = response.headers['x-total-available'];
const updateAvailable = response.headers['x-update-available'] === 'true';
return {
totalCount: parseInt(totalCount, 10),
value: response.data,
totalAvailable: parseInt(totalAvailable, 10),
updateAvailable,
};
} catch (e) {
throw parseAxiosError(e as Error);

View File

@ -78,5 +78,6 @@ export function useEnvironmentList(
environments: data ? data.value : [],
totalCount: data ? data.totalCount : 0,
totalAvailable: data ? data.totalAvailable : 0,
updateAvailable: data ? data.updateAvailable : false,
};
}

View File

@ -131,6 +131,7 @@ export type Environment = {
Edge: EnvironmentEdge;
SecuritySettings: EnvironmentSecuritySettings;
Gpus: { name: string; value: string }[];
LocalTimeZone?: string;
};
/**

View File

@ -3,14 +3,13 @@ import { Formik, Form as FormikForm } from 'formik';
import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import {
useRedirectFeatureFlag,
FeatureFlag,
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
import { isoDate } from '@/portainer/filters/filters';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
import { LoadingButton } from '@@/buttons';
import { TextTip } from '@@/Tip/TextTip';
import { ScheduleType } from '../types';
import { useCreateMutation } from '../queries/create';
@ -18,19 +17,21 @@ import { FormValues } from '../common/types';
import { validation } from '../common/validation';
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
import { useList } from '../queries/list';
import { EdgeGroupsField } from '../common/EdgeGroupsField';
import { NameField } from '../common/NameField';
import { EdgeGroupsField } from '../common/EdgeGroupsField';
import { BetaAlert } from '../common/BetaAlert';
const initialValues: FormValues = {
name: '',
groupIds: [],
type: ScheduleType.Update,
time: Math.floor(Date.now() / 1000) + 60 * 60,
environments: {},
};
export default withLimitToBE(CreateView);
function CreateView() {
const initialValues: FormValues = {
name: '',
groupIds: [],
type: ScheduleType.Update,
version: '',
scheduledTime: isoDate(Date.now() + 24 * 60 * 60 * 1000),
};
export function CreateView() {
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
const schedulesQuery = useList();
const createMutation = useCreateMutation();
@ -49,6 +50,8 @@ export function CreateView() {
breadcrumbs="Edge agent update and rollback"
/>
<BetaAlert />
<div className="row">
<div className="col-sm-12">
<Widget>
@ -60,11 +63,25 @@ export function CreateView() {
validateOnMount
validationSchema={() => validation(schedules)}
>
{({ isValid }) => (
{({ isValid, setFieldValue, values, handleBlur, errors }) => (
<FormikForm className="form-horizontal">
<NameField />
<EdgeGroupsField />
<EdgeGroupsField
onChange={(value) => setFieldValue('groupIds', value)}
value={values.groupIds}
onBlur={handleBlur}
error={errors.groupIds}
/>
<TextTip color="blue">
You can upgrade from any agent version to 2.17 or later
only. You can not upgrade to an agent version prior to
2.17 . The ability to rollback to originating version is
for 2.15.0+ only.
</TextTip>
<ScheduleTypeSelector />
<div className="form-group">
<div className="col-sm-12">
<LoadingButton

View File

@ -1 +1 @@
export { CreateView } from './CreateView';
export { default as CreateView } from './CreateView';

View File

@ -1,20 +1,17 @@
import { Settings } from 'lucide-react';
import { Formik, Form as FormikForm } from 'formik';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { useMemo } from 'react';
import { object, SchemaOf } from 'yup';
import { notifySuccess } from '@/portainer/services/notifications';
import {
useRedirectFeatureFlag,
FeatureFlag,
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
import { LoadingButton } from '@@/buttons';
import { TextTip } from '@@/Tip/TextTip';
import { InformationPanel } from '@@/InformationPanel';
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
import { useItem } from '../queries/useItem';
import { validation } from '../common/validation';
import { useUpdateMutation } from '../queries/useUpdateMutation';
@ -23,12 +20,12 @@ import { NameField, nameValidation } from '../common/NameField';
import { EdgeGroupsField } from '../common/EdgeGroupsField';
import { EdgeUpdateSchedule } from '../types';
import { FormValues } from '../common/types';
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
import { BetaAlert } from '../common/BetaAlert';
import { ScheduleDetails } from './ScheduleDetails';
export function ItemView() {
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
export default withLimitToBE(ItemView);
function ItemView() {
const {
params: { id: idParam },
} = useCurrentStateAndParams();
@ -44,31 +41,31 @@ export function ItemView() {
const itemQuery = useItem(id);
const schedulesQuery = useList();
const isDisabled = useMemo(
() => (itemQuery.data ? itemQuery.data.time < Date.now() / 1000 : false),
[itemQuery.data]
);
if (!itemQuery.data || !schedulesQuery.data) {
return null;
}
const item = itemQuery.data;
const isScheduleActive = item.isActive;
const schedules = schedulesQuery.data;
const initialValuesActive: Partial<FormValues> = {
name: item.name,
};
const initialValues: FormValues = {
name: item.name,
groupIds: item.groupIds,
groupIds: item.edgeGroupIds,
type: item.type,
time: item.time,
environments: Object.fromEntries(
Object.entries(item.status).map(([envId, status]) => [
parseInt(envId, 10),
status.targetVersion,
])
),
version: item.version,
scheduledTime: item.scheduledTime,
};
const environmentsCount = Object.keys(
item.environmentsPreviousVersions
).length;
return (
<>
<PageHeader
@ -79,13 +76,17 @@ export function ItemView() {
]}
/>
<BetaAlert />
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
<Widget.Body>
<Formik
initialValues={initialValues}
initialValues={
!isScheduleActive ? initialValues : initialValuesActive
}
onSubmit={(values) => {
updateMutation.mutate(
{ id, values },
@ -102,17 +103,33 @@ export function ItemView() {
}}
validateOnMount
validationSchema={() =>
updateValidation(item.id, item.time, schedules)
updateValidation(item.id, schedules, isScheduleActive)
}
>
{({ isValid }) => (
{({ isValid, setFieldValue, values, handleBlur, errors }) => (
<FormikForm className="form-horizontal">
<NameField />
<EdgeGroupsField disabled={isDisabled} />
<EdgeGroupsField
disabled={isScheduleActive}
onChange={(value) => setFieldValue('groupIds', value)}
value={
isScheduleActive
? item.edgeGroupIds
: values.groupIds || []
}
onBlur={handleBlur}
error={errors.groupIds}
/>
{isDisabled ? (
<ScheduleDetails schedule={item} />
{isScheduleActive ? (
<InformationPanel>
<TextTip color="blue">
{environmentsCount} environment(s) will be updated to
version {item.version} on {item.scheduledTime} (local
time)
</TextTip>
</InformationPanel>
) : (
<ScheduleTypeSelector />
)}
@ -141,10 +158,10 @@ export function ItemView() {
function updateValidation(
itemId: EdgeUpdateSchedule['id'],
scheduledTime: number,
schedules: EdgeUpdateSchedule[]
schedules: EdgeUpdateSchedule[],
isScheduleActive: boolean
): SchemaOf<{ name: string } | FormValues> {
return scheduledTime > Date.now() / 1000
return !isScheduleActive
? validation(schedules, itemId)
: object({ name: nameValidation(schedules, itemId) });
}

View File

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

View File

@ -1 +1 @@
export { ItemView } from './ItemView';
export { default as ItemView } from './ItemView';

View File

@ -1,12 +1,9 @@
import { Clock, Trash2 } from 'lucide-react';
import { useStore } from 'zustand';
import {
FeatureFlag,
useRedirectFeatureFlag,
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
import { notifySuccess } from '@/portainer/services/notifications';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
import { Datatable } from '@@/datatables';
import { PageHeader } from '@@/PageHeader';
@ -15,7 +12,7 @@ import { Link } from '@@/Link';
import { useSearchBarState } from '@@/datatables/SearchBar';
import { useList } from '../queries/list';
import { EdgeUpdateSchedule } from '../types';
import { EdgeUpdateSchedule, StatusType } from '../types';
import { useRemoveMutation } from '../queries/useRemoveMutation';
import { columns } from './columns';
@ -24,13 +21,13 @@ import { createStore } from './datatable-store';
const storageKey = 'update-schedules-list';
const settingsStore = createStore(storageKey);
export function ListView() {
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
export default withLimitToBE(ListView);
export function ListView() {
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const listQuery = useList();
const listQuery = useList(true);
if (!listQuery.data) {
return null;
@ -61,6 +58,7 @@ export function ListView() {
onSortByChange={settings.setSortBy}
searchValue={search}
onSearchChange={setSearch}
isRowSelectable={(row) => row.original.status === StatusType.Pending}
/>
</>
);

View File

@ -2,9 +2,9 @@ import { Column } from 'react-table';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { EdgeUpdateSchedule } from '../../types';
import { EdgeUpdateListItemResponse } from '../../queries/list';
export const created: Column<EdgeUpdateSchedule> = {
export const created: Column<EdgeUpdateListItemResponse> = {
Header: 'Created',
accessor: (row) => isoDateFromTimestamp(row.created),
disableFilters: true,

View File

@ -4,11 +4,11 @@ import _ from 'lodash';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
import { EdgeUpdateSchedule } from '../../types';
import { EdgeUpdateListItemResponse } from '../../queries/list';
export const groups: Column<EdgeUpdateSchedule> = {
export const groups: Column<EdgeUpdateListItemResponse> = {
Header: 'Groups',
accessor: 'groupIds',
accessor: 'edgeGroupIds',
Cell: GroupsCell,
disableFilters: true,
Filter: () => null,
@ -18,7 +18,7 @@ export const groups: Column<EdgeUpdateSchedule> = {
export function GroupsCell({
value: groupsIds,
}: CellProps<EdgeUpdateSchedule, Array<EdgeGroup['Id']>>) {
}: CellProps<EdgeUpdateListItemResponse, Array<EdgeGroup['Id']>>) {
const groupsQuery = useEdgeGroups();
const groups = _.compact(

View File

@ -2,9 +2,9 @@ import { CellProps, Column } from 'react-table';
import { Link } from '@@/Link';
import { EdgeUpdateSchedule } from '../../types';
import { EdgeUpdateListItemResponse } from '../../queries/list';
export const name: Column<EdgeUpdateSchedule> = {
export const name: Column<EdgeUpdateListItemResponse> = {
Header: 'Name',
accessor: 'name',
id: 'name',
@ -15,7 +15,10 @@ export const name: Column<EdgeUpdateSchedule> = {
sortType: 'string',
};
export function NameCell({ value: name, row }: CellProps<EdgeUpdateSchedule>) {
export function NameCell({
value: name,
row,
}: CellProps<EdgeUpdateListItemResponse>) {
return (
<Link to=".item" params={{ id: row.original.id }}>
{name}

View File

@ -1,8 +1,9 @@
import { CellProps, Column } from 'react-table';
import { EdgeUpdateSchedule, StatusType } from '../../types';
import { EdgeUpdateListItemResponse } from '../../queries/list';
import { StatusType } from '../../types';
export const scheduleStatus: Column<EdgeUpdateSchedule> = {
export const scheduleStatus: Column<EdgeUpdateListItemResponse> = {
Header: 'Status',
accessor: (row) => row.status,
disableFilters: true,
@ -14,31 +15,17 @@ export const scheduleStatus: Column<EdgeUpdateSchedule> = {
function StatusCell({
value: status,
row: { original: schedule },
}: CellProps<EdgeUpdateSchedule, EdgeUpdateSchedule['status']>) {
if (schedule.time > Date.now() / 1000) {
return 'Scheduled';
row: {
original: { statusMessage },
},
}: CellProps<
EdgeUpdateListItemResponse,
EdgeUpdateListItemResponse['status']
>) {
switch (status) {
case StatusType.Failed:
return statusMessage;
default:
return StatusType[status];
}
const statusList = Object.entries(status).map(
([environmentId, envStatus]) => ({ ...envStatus, environmentId })
);
if (statusList.length === 0) {
return 'No related environments';
}
const error = statusList.find((s) => s.status === StatusType.Failed);
if (error) {
return `Failed: (ID: ${error.environmentId}) ${error.error}`;
}
const pending = statusList.find((s) => s.status === StatusType.Pending);
if (pending) {
return 'Pending';
}
return 'Success';
}

View File

@ -1,12 +1,10 @@
import { Column } from 'react-table';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { EdgeUpdateListItemResponse } from '../../queries/list';
import { EdgeUpdateSchedule } from '../../types';
export const scheduledTime: Column<EdgeUpdateSchedule> = {
export const scheduledTime: Column<EdgeUpdateListItemResponse> = {
Header: 'Scheduled Time & Date',
accessor: (row) => isoDateFromTimestamp(row.time),
accessor: (row) => row.scheduledTime,
disableFilters: true,
Filter: () => null,
canHide: false,

View File

@ -1,8 +1,9 @@
import { Column } from 'react-table';
import { EdgeUpdateSchedule, ScheduleType } from '../../types';
import { ScheduleType } from '../../types';
import { EdgeUpdateListItemResponse } from '../../queries/list';
export const scheduleType: Column<EdgeUpdateSchedule> = {
export const scheduleType: Column<EdgeUpdateListItemResponse> = {
Header: 'Type',
accessor: (row) => ScheduleType[row.type],
disableFilters: true,

View File

@ -1 +1 @@
export { ListView } from './ListView';
export { default as ListView } from './ListView';

View File

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

View File

@ -1,4 +1,4 @@
import { useField } from 'formik';
import { FormikErrors, FormikHandlers } from 'formik';
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
@ -9,14 +9,21 @@ import { FormValues } from './types';
interface Props {
disabled?: boolean;
onBlur: FormikHandlers['handleBlur'];
value: FormValues['groupIds'];
error?: FormikErrors<FormValues>['groupIds'];
onChange(value: FormValues['groupIds']): void;
}
export function EdgeGroupsField({ disabled }: Props) {
export function EdgeGroupsField({
disabled,
onBlur,
value,
error,
onChange,
}: Props) {
const groupsQuery = useEdgeGroups();
const [{ name, onBlur, value }, { error }, { setValue }] =
useField<FormValues['groupIds']>('groupIds');
const selectedGroups = groupsQuery.data?.filter((group) =>
value.includes(group.Id)
);
@ -24,12 +31,12 @@ export function EdgeGroupsField({ disabled }: Props) {
return (
<FormControl label="Groups" required inputId="groups-select" errors={error}>
<Select
name={name}
name="groupIds"
onBlur={onBlur}
value={selectedGroups}
inputId="groups-select"
placeholder="Select one or multiple group(s)"
onChange={(selectedGroups) => setValue(selectedGroups.map((g) => g.Id))}
onChange={(selectedGroups) => onChange(selectedGroups.map((g) => g.Id))}
isMulti
options={groupsQuery.data || []}
getOptionLabel={(group) => group.Name}

View File

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

View File

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

View File

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

View File

@ -1,95 +1,11 @@
import { useFormikContext } from 'formik';
import { useEffect, useMemo } from 'react';
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
import { TextTip } from '@@/Tip/TextTip';
import { usePreviousVersions } from '../queries/usePreviousVersions';
import { FormValues } from './types';
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
import { RollbackOptions } from './RollbackOptions';
import { ScheduledTimeField } from './ScheduledTimeField';
export function RollbackScheduleDetailsFieldset() {
const environmentsCount = useSelectedEnvironmentsCount();
const { isLoading } = useSelectEnvironmentsOnMount();
const groupNames = useGroupNames();
if (isLoading || !groupNames) {
return null;
}
return (
<div className="mt-3">
{environmentsCount > 0 ? (
<div className="form-group">
<div className="col-sm-12">
{environmentsCount} edge device(s) from {groupNames} will rollback
to their previous versions
</div>
</div>
) : (
<TextTip>
The are no rollback options available for yor selected groups(s)
</TextTip>
)}
<RollbackOptions />
<ScheduledTimeField />
</div>
);
}
function useSelectedEnvironmentsCount() {
const {
values: { environments },
} = useFormikContext<FormValues>();
return Object.keys(environments).length;
}
function useSelectEnvironmentsOnMount() {
const previousVersionsQuery = usePreviousVersions();
const {
values: { groupIds },
setFieldValue,
} = useFormikContext<FormValues>();
const edgeGroupsEnvironmentIds = useEdgeGroupsEnvironmentIds(groupIds);
const envIdsToUpdate = useMemo(
() =>
previousVersionsQuery.data
? Object.fromEntries(
edgeGroupsEnvironmentIds
.map((id) => [id, previousVersionsQuery.data[id] || ''] as const)
.filter(([, version]) => !!version)
)
: [],
[edgeGroupsEnvironmentIds, previousVersionsQuery.data]
);
useEffect(() => {
setFieldValue('environments', envIdsToUpdate);
}, [envIdsToUpdate, setFieldValue]);
return { isLoading: previousVersionsQuery.isLoading };
}
function useGroupNames() {
const {
values: { groupIds },
} = useFormikContext<FormValues>();
const groupsQuery = useEdgeGroups({
select: (groups) => Object.fromEntries(groups.map((g) => [g.Id, g.Name])),
});
if (!groupsQuery.data) {
return null;
}
return groupIds.map((id) => groupsQuery.data[id]).join(', ');
}

View File

@ -1,4 +1,4 @@
import { useField } from 'formik';
import { useFormikContext } from 'formik';
import { number } from 'yup';
import { NavTabs } from '@@/NavTabs';
@ -10,7 +10,7 @@ import { UpdateScheduleDetailsFieldset } from './UpdateScheduleDetailsFieldset';
import { RollbackScheduleDetailsFieldset } from './RollbackScheduleDetailsFieldset';
export function ScheduleTypeSelector() {
const [{ value }, , { setValue }] = useField<FormValues['type']>('type');
const { values, setFieldValue } = useFormikContext<FormValues>();
return (
<div className="form-group">
@ -28,12 +28,17 @@ export function ScheduleTypeSelector() {
children: <RollbackScheduleDetailsFieldset />,
},
]}
selectedId={value}
onSelect={(value) => setValue(value)}
selectedId={values.type}
onSelect={handleChangeType}
/>
</div>
</div>
);
function handleChangeType(scheduleType: ScheduleType) {
setFieldValue('type', scheduleType);
setFieldValue('version', '');
}
}
export function typeValidation() {

View File

@ -1,9 +1,14 @@
import { useField } from 'formik';
import DateTimePicker from 'react-datetime-picker';
import { Calendar, X } from 'lucide-react';
import { useMemo } from 'react';
import { string } from 'yup';
import { useField } from 'formik';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import {
isoDate,
parseIsoDate,
TIME_FORMAT,
} from '@/portainer/filters/filters';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
@ -16,27 +21,49 @@ interface Props {
export function ScheduledTimeField({ disabled }: Props) {
const [{ name, value }, { error }, { setValue }] =
useField<FormValues['time']>('time');
useField<FormValues['scheduledTime']>('scheduledTime');
const dateValue = useMemo(() => new Date(value * 1000), [value]);
const dateValue = useMemo(() => parseIsoDate(value), [value]);
return (
<FormControl label="Schedule date & time" errors={error}>
{!disabled ? (
<DateTimePicker
format="y-MM-dd HH:mm:ss"
minDate={new Date()}
className="form-control [&>div]:border-0"
onChange={(date) => setValue(Math.floor(date.getTime() / 1000))}
onChange={(date) => setValue(isoDate(date.valueOf()))}
name={name}
value={dateValue}
calendarIcon={<Calendar className="lucide" />}
clearIcon={<X className="lucide" />}
disableClock
minDate={new Date(Date.now() - 24 * 60 * 60 * 1000)}
/>
) : (
<Input defaultValue={isoDateFromTimestamp(value)} disabled />
<Input defaultValue={value} disabled />
)}
</FormControl>
);
}
export function timeValidation() {
return string()
.required('Scheduled time is required')
.test(
'validFormat',
`Scheduled time must be in the format ${TIME_FORMAT}`,
(value) => isValidDate(parseIsoDate(value))
)
.test(
'validDate',
`Scheduled time must be bigger then ${isoDate(
Date.now() - 24 * 60 * 60 * 1000
)}`,
(value) =>
parseIsoDate(value).valueOf() > Date.now() - 24 * 60 * 60 * 1000
);
}
function isValidDate(date: Date) {
return date instanceof Date && !Number.isNaN(date.valueOf());
}

View File

@ -1,39 +1,58 @@
import { useFormikContext } from 'formik';
import { useCurrentStateAndParams } from '@uirouter/react';
import semverCompare from 'semver-compare';
import _ from 'lodash';
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
import { useActiveSchedules } from '../queries/useActiveSchedules';
import { TextTip } from '@@/Tip/TextTip';
import { ScheduledTimeField } from './ScheduledTimeField';
import { FormValues } from './types';
import { EnvironmentSelection } from './EnvironmentSelection';
import { ActiveSchedulesNotice } from './ActiveSchedulesNotice';
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
import { VersionSelect } from './VersionSelect';
import { ScheduledTimeField } from './ScheduledTimeField';
export function UpdateScheduleDetailsFieldset() {
const { values } = useFormikContext<FormValues>();
const edgeGroupsEnvironmentIds = useEdgeGroupsEnvironmentIds(values.groupIds);
const environmentIdsQuery = useEdgeGroupsEnvironmentIds(values.groupIds);
const edgeGroupsEnvironmentIds = environmentIdsQuery.data || [];
const environments = useEnvironments(edgeGroupsEnvironmentIds);
const activeSchedules = useRelevantActiveSchedules(edgeGroupsEnvironmentIds);
const minVersion = _.first(
_.compact<string>(environments.map((env) => env.Agent.Version)).sort(
(a, b) => semverCompare(a, b)
)
);
// old version is version that doesn't support scheduling of updates
const hasNoTimeZone = environments.some((env) => !env.LocalTimeZone);
const hasTimeZone = environments.some((env) => env.LocalTimeZone);
return (
<>
<ActiveSchedulesNotice
selectedEdgeGroupIds={values.groupIds}
activeSchedules={activeSchedules}
environments={environments}
/>
{edgeGroupsEnvironmentIds.length > 0 ? (
!!values.version && (
<TextTip color="blue">
{edgeGroupsEnvironmentIds.length} environment(s) will be updated to{' '}
{values.version}
</TextTip>
)
) : (
<TextTip color="orange">
No environments options for the selected edge groups
</TextTip>
)}
<EnvironmentSelection
activeSchedules={activeSchedules}
environments={environments}
/>
<VersionSelect minVersion={minVersion} />
<ScheduledTimeField />
{hasTimeZone && <ScheduledTimeField />}
{hasNoTimeZone && (
<TextTip>
These edge groups have older versions of the edge agent that do not
support scheduling, these will happen immediately
</TextTip>
)}
</>
);
}
@ -48,17 +67,3 @@ function useEnvironments(environmentsIds: Array<EnvironmentId>) {
return environmentsQuery.environments;
}
function useRelevantActiveSchedules(environmentIds: EnvironmentId[]) {
const { params } = useCurrentStateAndParams();
const scheduleId = params.id ? parseInt(params.id, 10) : 0;
const activeSchedulesQuery = useActiveSchedules(environmentIds);
return (
activeSchedulesQuery.data?.filter(
(schedule) => schedule.scheduleId !== scheduleId
) || []
);
}

View File

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

View File

@ -1,4 +1,3 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { ScheduleType } from '../types';
@ -7,6 +6,6 @@ export interface FormValues {
name: string;
groupIds: EdgeGroup['Id'][];
type: ScheduleType;
time: number;
environments: Record<EnvironmentId, string>;
version: string;
scheduledTime: string;
}

View File

@ -12,7 +12,7 @@ export function useEdgeGroupsEnvironmentIds(
Object.fromEntries(groups.map((g) => [g.Id, g.Endpoints])),
});
return useMemo(
const envIds = useMemo(
() =>
_.uniq(
_.compact(
@ -23,4 +23,12 @@ export function useEdgeGroupsEnvironmentIds(
),
[edgeGroupsIds, groupsQuery.data]
);
return useMemo(
() => ({
data: groupsQuery.data ? envIds : null,
isLoading: groupsQuery.isLoading,
}),
[envIds, groupsQuery.data, groupsQuery.isLoading]
);
}

View File

@ -1,6 +1,6 @@
import { array, number, object } from 'yup';
import { array, object, string } from 'yup';
import { EdgeUpdateSchedule } from '../types';
import { EdgeUpdateSchedule, ScheduleType } from '../types';
import { nameValidation } from './NameField';
import { typeValidation } from './ScheduleTypeSelector';
@ -13,9 +13,15 @@ export function validation(
groupIds: array().min(1, 'At least one group is required'),
name: nameValidation(schedules, currentId),
type: typeValidation(),
time: number()
.min(Date.now() / 1000)
.required(),
environments: object().default({}),
// time: number()
// .min(Date.now() / 1000)
// .required(),
version: string().when('type', {
is: ScheduleType.Update,
// update type
then: (schema) => schema.required('Version is required'),
// rollback
otherwise: (schema) => schema.required('No rollback options available'),
}),
});
}

View File

@ -25,7 +25,7 @@ async function create(schedule: FormValues) {
export function useCreateMutation() {
const queryClient = useQueryClient();
return useMutation(create, {
...withInvalidate(queryClient, [queryKeys.list()]),
...withInvalidate(queryClient, [queryKeys.base()]),
...withError(),
});
}

View File

@ -2,14 +2,21 @@ import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EdgeUpdateSchedule } from '../types';
import { EdgeUpdateResponse, StatusType } from '../types';
import { queryKeys } from './query-keys';
import { buildUrl } from './urls';
async function getList() {
export type EdgeUpdateListItemResponse = EdgeUpdateResponse & {
status: StatusType;
statusMessage: string;
};
async function getList(includeEdgeStacks?: boolean) {
try {
const { data } = await axios.get<EdgeUpdateSchedule[]>(buildUrl());
const { data } = await axios.get<EdgeUpdateListItemResponse[]>(buildUrl(), {
params: { includeEdgeStacks },
});
return data;
} catch (err) {
throw parseAxiosError(
@ -19,6 +26,8 @@ async function getList() {
}
}
export function useList() {
return useQuery(queryKeys.list(), getList);
export function useList(includeEdgeStacks?: boolean) {
return useQuery(queryKeys.list(includeEdgeStacks), () =>
getList(includeEdgeStacks)
);
}

View File

@ -3,10 +3,13 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
import { EdgeUpdateSchedule } from '../types';
export const queryKeys = {
list: () => ['edge', 'update_schedules'] as const,
item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.list(), id] as const,
base: () => ['edge', 'update_schedules'] as const,
list: (includeEdgeStacks?: boolean) =>
[...queryKeys.base(), { includeEdgeStacks }] as const,
item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.base(), id] as const,
activeSchedules: (environmentIds: EnvironmentId[]) =>
[queryKeys.list(), 'active', { environmentIds }] as const,
supportedAgentVersions: () => [queryKeys.list(), 'agent_versions'] as const,
previousVersions: () => [queryKeys.list(), 'previous_versions'] as const,
[...queryKeys.base(), 'active', { environmentIds }] as const,
supportedAgentVersions: () =>
[...queryKeys.base(), 'agent_versions'] as const,
previousVersions: () => [...queryKeys.base(), 'previous_versions'] as const,
};

View File

@ -2,7 +2,7 @@ import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EdgeUpdateSchedule } from '../types';
import { EdgeUpdateResponse, EdgeUpdateSchedule } from '../types';
import { queryKeys } from './query-keys';
import { buildUrl } from './urls';
@ -11,9 +11,15 @@ export function useItem(id: EdgeUpdateSchedule['id']) {
return useQuery(queryKeys.item(id), () => getItem(id));
}
type EdgeUpdateItemResponse = EdgeUpdateResponse & {
isActive: boolean;
};
async function getItem(id: EdgeUpdateSchedule['id']) {
try {
const { data } = await axios.get<EdgeUpdateSchedule>(buildUrl(id));
const { data } = await axios.get<EdgeUpdateItemResponse>(buildUrl(id), {
params: { includeEdgeStack: true },
});
return data;
} catch (err) {
throw parseAxiosError(

View File

@ -6,11 +6,21 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys } from './query-keys';
import { buildUrl } from './urls';
interface Options<T> {
select?: (data: Record<EnvironmentId, string>) => T;
onSuccess?(data: T): void;
enabled?: boolean;
}
export function usePreviousVersions<T = Record<EnvironmentId, string>>({
select,
}: { select?: (data: Record<EnvironmentId, string>) => T } = {}) {
onSuccess,
enabled,
}: Options<T> = {}) {
return useQuery(queryKeys.previousVersions(), getPreviousVersions, {
select,
onSuccess,
enabled,
});
}

View File

@ -23,7 +23,7 @@ export function useRemoveMutation() {
),
mutationOptions(
withInvalidate(queryClient, [queryKeys.list()]),
withInvalidate(queryClient, [queryKeys.base()]),
withError()
)
);

View File

@ -1,17 +1,32 @@
import { useQuery } from 'react-query';
import semverCompare from 'semver-compare';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { queryKeys } from './query-keys';
import { buildUrl } from './urls';
export function useSupportedAgentVersions<T = string[]>({
select,
}: { select?: (data: string[]) => T } = {}) {
export function useSupportedAgentVersions(
minVersion?: string,
{ onSuccess }: { onSuccess?(data: string[]): void } = {}
) {
return useQuery(
queryKeys.supportedAgentVersions(),
[...queryKeys.supportedAgentVersions(), { minVersion }],
getSupportedAgentVersions,
{ select }
{
select(versions) {
if (!minVersion) {
return versions;
}
return versions.filter(
(version) => semverCompare(version, minVersion) > 0
);
},
onSuccess,
...withError('failed fetching available agent versions'),
}
);
}

View File

@ -11,7 +11,7 @@ import { buildUrl } from './urls';
interface Update {
id: EdgeUpdateSchedule['id'];
values: FormValues;
values: Partial<FormValues>;
}
async function update({ id, values }: Update) {
@ -30,7 +30,7 @@ async function update({ id, values }: Update) {
export function useUpdateMutation() {
const queryClient = useQueryClient();
return useMutation(update, {
...withInvalidate(queryClient, [queryKeys.list()]),
...withInvalidate(queryClient, [queryKeys.base()]),
...withError(),
});
}

View File

@ -11,22 +11,23 @@ export enum StatusType {
Pending,
Failed,
Success,
}
interface Status {
status: StatusType;
error: string;
targetVersion: string;
currentVersion: string;
Sent,
}
export type EdgeUpdateSchedule = {
id: number;
name: string;
time: number;
groupIds: EdgeGroup['Id'][];
type: ScheduleType;
status: { [key: EnvironmentId]: Status };
created: number;
createdBy: UserId;
version: string;
environmentsPreviousVersions: Record<EnvironmentId, string>;
};
export type EdgeUpdateResponse = EdgeUpdateSchedule & {
// from edge stack:
edgeGroupIds: EdgeGroup['Id'][];
scheduledTime: string;
};

View File

@ -3,7 +3,6 @@ import { useRouter } from '@uirouter/react';
import { usePublicSettings } from '@/react/portainer/settings/queries';
export enum FeatureFlag {
EdgeRemoteUpdate = 'edgeRemoteUpdate',
BEUpgrade = 'beUpgrade',
}

View File

@ -9,10 +9,7 @@ import {
} from 'lucide-react';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import {
FeatureFlag,
useFeatureFlag,
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { SidebarItem } from './SidebarItem';
import { SidebarSection } from './SidebarSection';
@ -27,10 +24,6 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
select: (settings) => settings.TeamSync,
});
const isEdgeRemoteUpgradeEnabledQuery = useFeatureFlag(
FeatureFlag.EdgeRemoteUpdate
);
const showUsersSection =
!window.ddExtension && (isAdmin || (isTeamLeader && !teamSyncQuery.data));
@ -77,7 +70,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
label="Tags"
data-cy="portainerSidebar-environmentTags"
/>
{isEdgeRemoteUpgradeEnabledQuery.data && (
{isBE && (
<SidebarItem
to="portainer.endpoints.updateSchedules"
label="Update & Rollback"
@ -93,7 +86,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
data-cy="portainerSidebar-registries"
/>
{process.env.PORTAINER_EDITION !== 'CE' && (
{isBE && (
<SidebarItem
to="portainer.licenses"
label="Licenses"
@ -136,7 +129,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
data-cy="portainerSidebar-authentication"
/>
)}
{process.env.PORTAINER_EDITION !== 'CE' && (
{isBE && (
<SidebarItem
to="portainer.settings.cloud"
label="Cloud"