diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 50f3131c0..758bb0fd2 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -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, diff --git a/api/dataservices/edgeupdateschedule/edgeupdateschedule.go b/api/dataservices/edgeupdateschedule/edgeupdateschedule.go deleted file mode 100644 index 3044d6f09..000000000 --- a/api/dataservices/edgeupdateschedule/edgeupdateschedule.go +++ /dev/null @@ -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 -} diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go index fae9c0c43..b6e2ed6ec 100644 --- a/api/dataservices/interface.go +++ b/api/dataservices/interface.go @@ -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) diff --git a/api/datastore/services.go b/api/datastore/services.go index dc511a4aa..749067a22 100644 --- a/api/datastore/services.go +++ b/api/datastore/services.go @@ -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 diff --git a/api/edgetypes/edgetypes.go b/api/edgetypes/edgetypes.go deleted file mode 100644 index b45ce15b9..000000000 --- a/api/edgetypes/edgetypes.go +++ /dev/null @@ -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 -) diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go index 9048e8585..c2f9414ff 100644 --- a/api/http/handler/edgestacks/edgestack_create.go +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -3,20 +3,16 @@ package edgestacks import ( "fmt" "net/http" - "strconv" - "strings" - "time" + "github.com/asaskevich/govalidator" + "github.com/pkg/errors" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/filesystem" - "github.com/portainer/portainer/api/internal/edge" - - "github.com/asaskevich/govalidator" - "github.com/pkg/errors" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/security" ) type InvalidPayloadError struct { @@ -47,8 +43,14 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) if err != nil { return httperror.BadRequest("Invalid query parameter: method", err) } + dryrun, _ := request.RetrieveBooleanQueryParameter(r, "dryrun", true) - edgeStack, err := handler.createSwarmStack(method, r) + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve user details from authentication token", err) + } + + edgeStack, err := handler.createSwarmStack(method, dryrun, tokenData.ID, r) if err != nil { var payloadError *InvalidPayloadError switch { @@ -62,14 +64,15 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) return response.JSON(w, edgeStack) } -func (handler *Handler) createSwarmStack(method string, r *http.Request) (*portainer.EdgeStack, error) { +func (handler *Handler) createSwarmStack(method string, dryrun bool, userID portainer.UserID, r *http.Request) (*portainer.EdgeStack, error) { + switch method { case "string": - return handler.createSwarmStackFromFileContent(r) + return handler.createSwarmStackFromFileContent(r, dryrun) case "repository": - return handler.createSwarmStackFromGitRepository(r) + return handler.createSwarmStackFromGitRepository(r, dryrun, userID) case "file": - return handler.createSwarmStackFromFileUpload(r) + return handler.createSwarmStackFromFileUpload(r, dryrun) } return nil, errors.New("Invalid value for query parameter: method. Value must be one of: string, repository or file") } @@ -82,10 +85,13 @@ type swarmStackFromFileContentPayload struct { // List of identifiers of EdgeGroups EdgeGroups []portainer.EdgeGroupID `example:"1"` // Deployment type to deploy this stack - // Valid values are: 0 - 'compose', 1 - 'kubernetes' + // Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad' // for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints) - // kubernetes deploytype is enabled only for kubernetes environments(endpoints) - DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"` + // kubernetes deploy type is enabled only for kubernetes environments(endpoints) + // nomad deploy type is enabled only for nomad environments(endpoints) + DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"` + // List of Registries to use for this stack + Registries []portainer.RegistryID } func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error { @@ -101,85 +107,69 @@ func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error return nil } -func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*portainer.EdgeStack, error) { +func (handler *Handler) createSwarmStackFromFileContent(r *http.Request, dryrun bool) (*portainer.EdgeStack, error) { var payload swarmStackFromFileContentPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { return nil, err } - err = handler.validateUniqueName(payload.Name) + stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries) if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to create Edge stack object") } - stackID := handler.DataStore.EdgeStack().GetNextIdentifier() - stack := &portainer.EdgeStack{ - ID: portainer.EdgeStackID(stackID), - Name: payload.Name, - DeploymentType: payload.DeploymentType, - CreationDate: time.Now().Unix(), - EdgeGroups: payload.EdgeGroups, - Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), - Version: 1, + if dryrun { + return stack, nil } - relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore) - if err != nil { - return nil, fmt.Errorf("unable to find environment relations in database: %w", err) - } + return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { + return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, []byte(payload.StackFileContent)) + }) - relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups) - if err != nil { - return nil, fmt.Errorf("unable to persist environment relation in database: %w", err) - } +} - stackFolder := strconv.Itoa(int(stack.ID)) - if stack.DeploymentType == portainer.EdgeStackDeploymentCompose { - stack.EntryPoint = filesystem.ComposeFileDefaultName +func (handler *Handler) storeFileContent(stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) { + if deploymentType == portainer.EdgeStackDeploymentCompose { + composePath = filesystem.ComposeFileDefaultName - projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, composePath, fileContent) if err != nil { - return nil, err + return "", "", "", err } - stack.ProjectPath = projectPath - err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds) + manifestPath, err = handler.convertAndStoreKubeManifestIfNeeded(stackFolder, projectPath, composePath, relatedEndpointIds) if err != nil { - return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err) + return "", "", "", fmt.Errorf("Failed creating and storing kube manifest: %w", err) } - } else { - hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds) - if err != nil { - return nil, fmt.Errorf("unable to check for existence of docker endpoint: %w", err) - } + return composePath, manifestPath, projectPath, nil - if hasDockerEndpoint { - return nil, fmt.Errorf("edge stack with docker endpoint cannot be deployed with kubernetes config") - } - - stack.ManifestPath = filesystem.ManifestFileDefaultName - - projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent)) - if err != nil { - return nil, err - } - - stack.ProjectPath = projectPath } - err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds) + hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds) if err != nil { - return nil, fmt.Errorf("Unable to update endpoint relations: %w", err) + return "", "", "", fmt.Errorf("unable to check for existence of docker environment: %w", err) } - err = handler.DataStore.EdgeStack().Create(stack.ID, stack) - if err != nil { - return nil, err + if hasDockerEndpoint { + return "", "", "", fmt.Errorf("edge stack with docker environment cannot be deployed with kubernetes or nomad config") } - return stack, nil + if deploymentType == portainer.EdgeStackDeploymentKubernetes { + + manifestPath = filesystem.ManifestFileDefaultName + + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, fileContent) + if err != nil { + return "", "", "", err + } + + return "", manifestPath, projectPath, nil + + } + + return "", "", "", fmt.Errorf("invalid deployment type: %d", deploymentType) } type swarmStackFromGitRepositoryPayload struct { @@ -200,10 +190,13 @@ type swarmStackFromGitRepositoryPayload struct { // List of identifiers of EdgeGroups EdgeGroups []portainer.EdgeGroupID `example:"1"` // Deployment type to deploy this stack - // Valid values are: 0 - 'compose', 1 - 'kubernetes' + // Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad' // for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints) - // kubernetes deploytype is enabled only for kubernetes environments(endpoints) - DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"` + // kubernetes deploy type is enabled only for kubernetes environments(endpoints) + // nomad deploy type is enabled only for nomad environments(endpoints) + DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"` + // List of Registries to use for this stack + Registries []portainer.RegistryID } func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { @@ -217,7 +210,12 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err return &InvalidPayloadError{msg: "Invalid repository credentials. Password must be specified when authentication is enabled"} } if govalidator.IsNull(payload.FilePathInRepository) { - payload.FilePathInRepository = filesystem.ComposeFileDefaultName + switch payload.DeploymentType { + case portainer.EdgeStackDeploymentCompose: + payload.FilePathInRepository = filesystem.ComposeFileDefaultName + case portainer.EdgeStackDeploymentKubernetes: + payload.FilePathInRepository = filesystem.ManifestFileDefaultName + } } if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 { return &InvalidPayloadError{msg: "Edge Groups are mandatory for an Edge stack"} @@ -225,83 +223,50 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err return nil } -func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*portainer.EdgeStack, error) { +func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) { var payload swarmStackFromGitRepositoryPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { return nil, err } - err = handler.validateUniqueName(payload.Name) + stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries) if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to create edge stack object") } - stackID := handler.DataStore.EdgeStack().GetNextIdentifier() - stack := &portainer.EdgeStack{ - ID: portainer.EdgeStackID(stackID), - Name: payload.Name, - CreationDate: time.Now().Unix(), - EdgeGroups: payload.EdgeGroups, - Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), - DeploymentType: payload.DeploymentType, - Version: 1, + if dryrun { + return stack, nil } - projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID))) - stack.ProjectPath = projectPath - - repositoryUsername := payload.RepositoryUsername - repositoryPassword := payload.RepositoryPassword - if !payload.RepositoryAuthentication { - repositoryUsername = "" - repositoryPassword = "" + repoConfig := gittypes.RepoConfig{ + URL: payload.RepositoryURL, + ReferenceName: payload.RepositoryReferenceName, } - relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore) - if err != nil { - return nil, fmt.Errorf("failed fetching relations config: %w", err) - } - - relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups) - if err != nil { - return nil, fmt.Errorf("unable to retrieve related endpoints: %w", err) - } - - err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword) - if err != nil { - return nil, err - } - - if stack.DeploymentType == portainer.EdgeStackDeploymentCompose { - stack.EntryPoint = payload.FilePathInRepository - - err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds) - if err != nil { - return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err) + if payload.RepositoryAuthentication { + repoConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, } - } else { - stack.ManifestPath = payload.FilePathInRepository } - err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds) - if err != nil { - return nil, fmt.Errorf("Unable to update environment relations: %w", err) - } - - err = handler.DataStore.EdgeStack().Create(stack.ID, stack) - if err != nil { - return nil, err - } - - return stack, nil + return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { + return handler.storeManifestFromGitRepository(stackFolder, relatedEndpointIds, payload.DeploymentType, userID, repoConfig) + }) } type swarmStackFromFileUploadPayload struct { Name string StackFileContent []byte EdgeGroups []portainer.EdgeGroupID - DeploymentType portainer.EdgeStackDeploymentType + // Deployment type to deploy this stack + // Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad' + // for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints) + // kubernetes deploytype is enabled only for kubernetes environments(endpoints) + // nomad deploytype is enabled only for nomad environments(endpoints) + DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"` + Registries []portainer.RegistryID } func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error { @@ -330,109 +295,67 @@ func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error } payload.DeploymentType = portainer.EdgeStackDeploymentType(deploymentType) + var registries []portainer.RegistryID + request.RetrieveMultiPartFormJSONValue(r, "Registries", ®istries, false) + if err != nil { + return errors.New("Invalid registry type") + } + payload.Registries = registries + return nil } -func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portainer.EdgeStack, error) { +func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request, dryrun bool) (*portainer.EdgeStack, error) { payload := &swarmStackFromFileUploadPayload{} err := payload.Validate(r) if err != nil { return nil, err } - err = handler.validateUniqueName(payload.Name) + stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries) if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to create edge stack object") } - stackID := handler.DataStore.EdgeStack().GetNextIdentifier() - stack := &portainer.EdgeStack{ - ID: portainer.EdgeStackID(stackID), - Name: payload.Name, - DeploymentType: payload.DeploymentType, - CreationDate: time.Now().Unix(), - EdgeGroups: payload.EdgeGroups, - Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), - Version: 1, + if dryrun { + return stack, nil } - relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore) - if err != nil { - return nil, fmt.Errorf("failed fetching relations config: %w", err) - } - - relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups) - if err != nil { - return nil, fmt.Errorf("unable to retrieve related endpoints: %w", err) - } - - stackFolder := strconv.Itoa(int(stack.ID)) - if stack.DeploymentType == portainer.EdgeStackDeploymentCompose { - stack.EntryPoint = filesystem.ComposeFileDefaultName - - projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) - if err != nil { - return nil, err - } - stack.ProjectPath = projectPath - - err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds) - if err != nil { - return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err) - } - - } else { - stack.ManifestPath = filesystem.ManifestFileDefaultName - - projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent)) - if err != nil { - return nil, err - } - stack.ProjectPath = projectPath - } - - err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds) - if err != nil { - return nil, fmt.Errorf("Unable to update environment relations: %w", err) - } - - err = handler.DataStore.EdgeStack().Create(stack.ID, stack) - if err != nil { - return nil, err - } - - return stack, nil + return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { + return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, payload.StackFileContent) + }) } -func (handler *Handler) validateUniqueName(name string) error { - edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() +func (handler *Handler) storeManifestFromGitRepository(stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) { + projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder) + repositoryUsername := "" + repositoryPassword := "" + if repositoryConfig.Authentication != nil { + if repositoryConfig.Authentication.Password != "" { + repositoryUsername = repositoryConfig.Authentication.Username + repositoryPassword = repositoryConfig.Authentication.Password + } + } + + err = handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword) if err != nil { - return err + return "", "", "", err } - for _, stack := range edgeStacks { - if strings.EqualFold(stack.Name, name) { - return errors.New("Edge stack name must be unique") - } - } - return nil -} + if deploymentType == portainer.EdgeStackDeploymentCompose { + composePath := repositoryConfig.ConfigFilePath -// updateEndpointRelations adds a relation between the Edge Stack to the related environments(endpoints) -func updateEndpointRelations(endpointRelationService dataservices.EndpointRelationService, edgeStackID portainer.EdgeStackID, relatedEndpointIds []portainer.EndpointID) error { - for _, endpointID := range relatedEndpointIds { - relation, err := endpointRelationService.EndpointRelation(endpointID) + manifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, projectPath, composePath, relatedEndpointIds) if err != nil { - return fmt.Errorf("unable to find environment relation in database: %w", err) + return "", "", "", fmt.Errorf("Failed creating and storing kube manifest: %w", err) } - relation.EdgeStacks[edgeStackID] = true - - err = endpointRelationService.UpdateEndpointRelation(endpointID, relation) - if err != nil { - return fmt.Errorf("unable to persist environment relation in database: %w", err) - } + return composePath, manifestPath, projectPath, nil } - return nil + if deploymentType == portainer.EdgeStackDeploymentKubernetes { + return "", repositoryConfig.ConfigFilePath, projectPath, nil + } + + return "", "", "", fmt.Errorf("unknown deployment type: %d", deploymentType) } diff --git a/api/http/handler/edgestacks/edgestack_delete.go b/api/http/handler/edgestacks/edgestack_delete.go index 2d1dff2b0..2ae611e2e 100644 --- a/api/http/handler/edgestacks/edgestack_delete.go +++ b/api/http/handler/edgestacks/edgestack_delete.go @@ -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) diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go index d0f84eaf1..bbc7f46e6 100644 --- a/api/http/handler/edgestacks/edgestack_status_update.go +++ b/api/http/handler/edgestacks/edgestack_status_update.go @@ -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) diff --git a/api/http/handler/edgestacks/edgestack_test.go b/api/http/handler/edgestacks/edgestack_test.go index 064ed6a63..f9e76d193 100644 --- a/api/http/handler/edgestacks/edgestack_test.go +++ b/api/http/handler/edgestacks/edgestack_test.go @@ -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, diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go index 05bd2fd6a..9bc690f31 100644 --- a/api/http/handler/edgestacks/edgestack_update.go +++ b/api/http/handler/edgestacks/edgestack_update.go @@ -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{} } diff --git a/api/http/handler/edgestacks/endpoints.go b/api/http/handler/edgestacks/endpoints.go index c202ff963..9a9dde2ff 100644 --- a/api/http/handler/edgestacks/endpoints.go +++ b/api/http/handler/edgestacks/endpoints.go @@ -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 -} diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go index ad4a7d17b..1a9e39617 100644 --- a/api/http/handler/edgestacks/handler.go +++ b/api/http/handler/edgestacks/handler.go @@ -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 } diff --git a/api/http/handler/edgeupdateschedules/agentversions.go b/api/http/handler/edgeupdateschedules/agentversions.go deleted file mode 100644 index 491fbeae5..000000000 --- a/api/http/handler/edgeupdateschedules/agentversions.go +++ /dev/null @@ -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 - }) -} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_activeschedules.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_activeschedules.go deleted file mode 100644 index dc4944082..000000000 --- a/api/http/handler/edgeupdateschedules/edgeupdateschedule_activeschedules.go +++ /dev/null @@ -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) -} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_create.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_create.go deleted file mode 100644 index 394f3a594..000000000 --- a/api/http/handler/edgeupdateschedules/edgeupdateschedule_create.go +++ /dev/null @@ -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) -} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_delete.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_delete.go deleted file mode 100644 index deaecc11d..000000000 --- a/api/http/handler/edgeupdateschedules/edgeupdateschedule_delete.go +++ /dev/null @@ -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) -} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_inspect.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_inspect.go deleted file mode 100644 index 51d0f65d7..000000000 --- a/api/http/handler/edgeupdateschedules/edgeupdateschedule_inspect.go +++ /dev/null @@ -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) -} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_list.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_list.go deleted file mode 100644 index 04b875d58..000000000 --- a/api/http/handler/edgeupdateschedules/edgeupdateschedule_list.go +++ /dev/null @@ -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) -} diff --git a/api/http/handler/edgeupdateschedules/edgeupdateschedule_update.go b/api/http/handler/edgeupdateschedules/edgeupdateschedule_update.go deleted file mode 100644 index 47ec3970b..000000000 --- a/api/http/handler/edgeupdateschedules/edgeupdateschedule_update.go +++ /dev/null @@ -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) -} diff --git a/api/http/handler/edgeupdateschedules/handler.go b/api/http/handler/edgeupdateschedules/handler.go deleted file mode 100644 index 76c9f1266..000000000 --- a/api/http/handler/edgeupdateschedules/handler.go +++ /dev/null @@ -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 -} diff --git a/api/http/handler/edgeupdateschedules/previous_versions.go b/api/http/handler/edgeupdateschedules/previous_versions.go deleted file mode 100644 index 215a57af0..000000000 --- a/api/http/handler/edgeupdateschedules/previous_versions.go +++ /dev/null @@ -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 -} diff --git a/api/http/handler/edgeupdateschedules/previous_versions_test.go b/api/http/handler/edgeupdateschedules/previous_versions_test.go deleted file mode 100644 index e1b94faa4..000000000 --- a/api/http/handler/edgeupdateschedules/previous_versions_test.go +++ /dev/null @@ -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) - -} diff --git a/api/http/handler/edgeupdateschedules/utils.go b/api/http/handler/edgeupdateschedules/utils.go deleted file mode 100644 index 10141df32..000000000 --- a/api/http/handler/edgeupdateschedules/utils.go +++ /dev/null @@ -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 -} diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 89d43fb90..19c91aa1c 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -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"): diff --git a/api/http/server.go b/api/http/server.go index e9cbc1244..6eb1dab0d 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -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)) diff --git a/api/internal/edge/edgestack.go b/api/internal/edge/edgestack.go index 5e2aa520b..cb9f363dc 100644 --- a/api/internal/edge/edgestack.go +++ b/api/internal/edge/edgestack.go @@ -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 +} diff --git a/api/internal/edge/edgestacks/service.go b/api/internal/edge/edgestacks/service.go new file mode 100644 index 000000000..b08e4ae01 --- /dev/null +++ b/api/internal/edge/edgestacks/service.go @@ -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 +} diff --git a/api/http/handler/edgestacks/edgestack_create_test.go b/api/internal/edge/edgestacks/service_test.go similarity index 92% rename from api/http/handler/edgestacks/edgestack_create_test.go rename to api/internal/edge/edgestacks/service_test.go index a519e741c..47d99f2ac 100644 --- a/api/http/handler/edgestacks/edgestack_create_test.go +++ b/api/internal/edge/edgestacks/service_test.go @@ -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") diff --git a/api/internal/edge/types/stacks.go b/api/internal/edge/types/stacks.go new file mode 100644 index 000000000..e4b50ef62 --- /dev/null +++ b/api/internal/edge/types/stacks.go @@ -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) diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 642aaa1d4..88346bfdc 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -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 } diff --git a/api/portainer.go b/api/portainer.go index 2cb11e026..ff55d12be 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -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, } diff --git a/app/portainer/filters/filters.js b/app/portainer/filters/filters.js index ec2ffdfc2..c99d8a9ec 100644 --- a/app/portainer/filters/filters.js +++ b/app/portainer/filters/filters.js @@ -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) { diff --git a/app/react/components/Link.tsx b/app/react/components/Link.tsx index 92c96d238..ef82951b9 100644 --- a/app/react/components/Link.tsx +++ b/app/react/components/Link.tsx @@ -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 & UISrefProps) { return ( // eslint-disable-next-line react/jsx-props-no-spreading - + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} {children} diff --git a/app/react/components/buttons/Button.tsx b/app/react/components/buttons/Button.tsx index 609e0328e..6151f0348 100644 --- a/app/react/components/buttons/Button.tsx +++ b/app/react/components/buttons/Button.tsx @@ -61,9 +61,7 @@ export function Button({ // eslint-disable-next-line react/jsx-props-no-spreading {...ariaProps} > - {icon && ( - - )} + {icon && } {children} ); diff --git a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx index 37a7f2883..c6aa397df 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx @@ -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 ( <> ( + WrappedComponent: ComponentType, + defaultPath = 'portainer.home' +): ComponentType { + // 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 ; + } + + WrapperComponent.displayName = `withLimitToBE(${displayName})`; + + return WrapperComponent; +} diff --git a/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx index fc95c3825..8a3d95ed8 100644 --- a/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx +++ b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx @@ -81,7 +81,10 @@ export function IngressDatatable() { - + diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx index df0e5e0f4..e94247481 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx @@ -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 && } - - + + {isBE && updateAvailable && } +
diff --git a/app/react/portainer/HomeView/EnvironmentList/UpdateBadge.tsx b/app/react/portainer/HomeView/EnvironmentList/UpdateBadge.tsx new file mode 100644 index 000000000..284b03acd --- /dev/null +++ b/app/react/portainer/HomeView/EnvironmentList/UpdateBadge.tsx @@ -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 ( + + Update Available: Edge Agent {version} + + Schedule Update + + + ); +} + +function useAgentLatestVersion() { + const supportedAgentVersionsQuery = useSupportedAgentVersions(); + + return _.last(supportedAgentVersionsQuery.data) || ''; +} diff --git a/app/react/portainer/environments/environment.service/index.ts b/app/react/portainer/environments/environment.service/index.ts index f9ae49365..3131c7c62 100644 --- a/app/react/portainer/environments/environment.service/index.ts +++ b/app/react/portainer/environments/environment.service/index.ts @@ -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: [] }; + return { + totalCount: 0, + value: [], + totalAvailable: 0, + updateAvailable: false, + }; } const url = buildUrl(); @@ -63,11 +69,13 @@ export async function getEnvironments( const response = await axios.get(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); diff --git a/app/react/portainer/environments/queries/useEnvironmentList.ts b/app/react/portainer/environments/queries/useEnvironmentList.ts index 1f68324d8..de05d4772 100644 --- a/app/react/portainer/environments/queries/useEnvironmentList.ts +++ b/app/react/portainer/environments/queries/useEnvironmentList.ts @@ -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, }; } diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts index 197d429c2..1e60b9716 100644 --- a/app/react/portainer/environments/types.ts +++ b/app/react/portainer/environments/types.ts @@ -131,6 +131,7 @@ export type Environment = { Edge: EnvironmentEdge; SecuritySettings: EnvironmentSecuritySettings; Gpus: { name: string; value: string }[]; + LocalTimeZone?: string; }; /** diff --git a/app/react/portainer/environments/update-schedules/CreateView/CreateView.tsx b/app/react/portainer/environments/update-schedules/CreateView/CreateView.tsx index 4385b5a4b..239b5407c 100644 --- a/app/react/portainer/environments/update-schedules/CreateView/CreateView.tsx +++ b/app/react/portainer/environments/update-schedules/CreateView/CreateView.tsx @@ -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" /> + +
@@ -60,11 +63,25 @@ export function CreateView() { validateOnMount validationSchema={() => validation(schedules)} > - {({ isValid }) => ( + {({ isValid, setFieldValue, values, handleBlur, errors }) => ( - + setFieldValue('groupIds', value)} + value={values.groupIds} + onBlur={handleBlur} + error={errors.groupIds} + /> + + + 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. + + +
(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 = { + 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 ( <> + +
{ 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 }) => ( - + setFieldValue('groupIds', value)} + value={ + isScheduleActive + ? item.edgeGroupIds + : values.groupIds || [] + } + onBlur={handleBlur} + error={errors.groupIds} + /> - {isDisabled ? ( - + {isScheduleActive ? ( + + + {environmentsCount} environment(s) will be updated to + version {item.version} on {item.scheduledTime} (local + time) + + ) : ( )} @@ -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) }); } diff --git a/app/react/portainer/environments/update-schedules/ItemView/ScheduleDetails.tsx b/app/react/portainer/environments/update-schedules/ItemView/ScheduleDetails.tsx deleted file mode 100644 index 5b2d64983..000000000 --- a/app/react/portainer/environments/update-schedules/ItemView/ScheduleDetails.tsx +++ /dev/null @@ -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 ( -
-
- , - }, - { - id: ScheduleType.Rollback, - label: 'Rollback', - children: , - }, - ]} - selectedId={schedule.type} - onSelect={() => {}} - disabled - /> -
-
- ); -} - -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 ( - <> -
-
- {schedulesCount.map(({ count, currentVersion, targetVersion }) => ( -
- {count} edge device(s) selected for{' '} - {schedule.type === ScheduleType.Rollback ? 'rollback' : 'update'}{' '} - from v{currentVersion} to v{targetVersion} -
- ))} -
-
- - - - ); -} diff --git a/app/react/portainer/environments/update-schedules/ItemView/index.ts b/app/react/portainer/environments/update-schedules/ItemView/index.ts index a09ab2dde..9c264d407 100644 --- a/app/react/portainer/environments/update-schedules/ItemView/index.ts +++ b/app/react/portainer/environments/update-schedules/ItemView/index.ts @@ -1 +1 @@ -export { ItemView } from './ItemView'; +export { default as ItemView } from './ItemView'; diff --git a/app/react/portainer/environments/update-schedules/ListView/ListView.tsx b/app/react/portainer/environments/update-schedules/ListView/ListView.tsx index 2b3dba77e..820cc8f33 100644 --- a/app/react/portainer/environments/update-schedules/ListView/ListView.tsx +++ b/app/react/portainer/environments/update-schedules/ListView/ListView.tsx @@ -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} /> ); diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/created.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/created.tsx index 5b4622359..562c368c4 100644 --- a/app/react/portainer/environments/update-schedules/ListView/columns/created.tsx +++ b/app/react/portainer/environments/update-schedules/ListView/columns/created.tsx @@ -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 = { +export const created: Column = { Header: 'Created', accessor: (row) => isoDateFromTimestamp(row.created), disableFilters: true, diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/groups.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/groups.tsx index aca5846e4..bc86712cd 100644 --- a/app/react/portainer/environments/update-schedules/ListView/columns/groups.tsx +++ b/app/react/portainer/environments/update-schedules/ListView/columns/groups.tsx @@ -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 = { +export const groups: Column = { Header: 'Groups', - accessor: 'groupIds', + accessor: 'edgeGroupIds', Cell: GroupsCell, disableFilters: true, Filter: () => null, @@ -18,7 +18,7 @@ export const groups: Column = { export function GroupsCell({ value: groupsIds, -}: CellProps>) { +}: CellProps>) { const groupsQuery = useEdgeGroups(); const groups = _.compact( diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/name.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/name.tsx index de3b8bcd6..924d43e3b 100644 --- a/app/react/portainer/environments/update-schedules/ListView/columns/name.tsx +++ b/app/react/portainer/environments/update-schedules/ListView/columns/name.tsx @@ -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 = { +export const name: Column = { Header: 'Name', accessor: 'name', id: 'name', @@ -15,7 +15,10 @@ export const name: Column = { sortType: 'string', }; -export function NameCell({ value: name, row }: CellProps) { +export function NameCell({ + value: name, + row, +}: CellProps) { return ( {name} diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/schedule-status.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/schedule-status.tsx index 768047990..7256a27e9 100644 --- a/app/react/portainer/environments/update-schedules/ListView/columns/schedule-status.tsx +++ b/app/react/portainer/environments/update-schedules/ListView/columns/schedule-status.tsx @@ -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 = { +export const scheduleStatus: Column = { Header: 'Status', accessor: (row) => row.status, disableFilters: true, @@ -14,31 +15,17 @@ export const scheduleStatus: Column = { function StatusCell({ value: status, - row: { original: schedule }, -}: CellProps) { - 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'; } diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/scheduled-time.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/scheduled-time.tsx index 0c9640c25..62e87835e 100644 --- a/app/react/portainer/environments/update-schedules/ListView/columns/scheduled-time.tsx +++ b/app/react/portainer/environments/update-schedules/ListView/columns/scheduled-time.tsx @@ -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 = { +export const scheduledTime: Column = { Header: 'Scheduled Time & Date', - accessor: (row) => isoDateFromTimestamp(row.time), + accessor: (row) => row.scheduledTime, disableFilters: true, Filter: () => null, canHide: false, diff --git a/app/react/portainer/environments/update-schedules/ListView/columns/type.tsx b/app/react/portainer/environments/update-schedules/ListView/columns/type.tsx index 681947f94..99e99a2da 100644 --- a/app/react/portainer/environments/update-schedules/ListView/columns/type.tsx +++ b/app/react/portainer/environments/update-schedules/ListView/columns/type.tsx @@ -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 = { +export const scheduleType: Column = { Header: 'Type', accessor: (row) => ScheduleType[row.type], disableFilters: true, diff --git a/app/react/portainer/environments/update-schedules/ListView/index.ts b/app/react/portainer/environments/update-schedules/ListView/index.ts index dd06dfd19..717a65b52 100644 --- a/app/react/portainer/environments/update-schedules/ListView/index.ts +++ b/app/react/portainer/environments/update-schedules/ListView/index.ts @@ -1 +1 @@ -export { ListView } from './ListView'; +export { default as ListView } from './ListView'; diff --git a/app/react/portainer/environments/update-schedules/common/BetaAlert.tsx b/app/react/portainer/environments/update-schedules/common/BetaAlert.tsx new file mode 100644 index 000000000..44f9c2897 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/common/BetaAlert.tsx @@ -0,0 +1,13 @@ +import { InformationPanel } from '@@/InformationPanel'; +import { TextTip } from '@@/Tip/TextTip'; + +export function BetaAlert() { + return ( + + + This feature is currently in beta and is limited to standalone linux + edge devices. + + + ); +} diff --git a/app/react/portainer/environments/update-schedules/common/EdgeGroupsField.tsx b/app/react/portainer/environments/update-schedules/common/EdgeGroupsField.tsx index 4b95e7f2f..86e9de5af 100644 --- a/app/react/portainer/environments/update-schedules/common/EdgeGroupsField.tsx +++ b/app/react/portainer/environments/update-schedules/common/EdgeGroupsField.tsx @@ -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['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('groupIds'); - const selectedGroups = groupsQuery.data?.filter((group) => value.includes(group.Id) ); @@ -24,12 +31,12 @@ export function EdgeGroupsField({ disabled }: Props) { return ( - -
- ); - - function handleVersionChange(e: ChangeEvent) { - 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); - } -} diff --git a/app/react/portainer/environments/update-schedules/common/RollbackOptions.tsx b/app/react/portainer/environments/update-schedules/common/RollbackOptions.tsx new file mode 100644 index 000000000..e0b40cf3d --- /dev/null +++ b/app/react/portainer/environments/update-schedules/common/RollbackOptions.tsx @@ -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 {versionError}; + } + + if (!count) { + return ( + + The are no rollback options available for yor selected groups(s) + + ); + } + + if (isLoading || !groupNames) { + return null; + } + + return ( +
+
+ {count} edge device(s) from {groupNames} will rollback to version{' '} + {version} +
+
+ ); +} + +function useSelectVersionOnMount() { + const { + values: { groupIds, version }, + setFieldValue, + setFieldError, + errors: { version: versionError }, + } = useFormikContext(); + + const environmentIdsQuery = useEdgeGroupsEnvironmentIds(groupIds); + + const previousVersionsQuery = usePreviousVersions({ + 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(); + + 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(', '); +} diff --git a/app/react/portainer/environments/update-schedules/common/RollbackScheduleDetailsFieldset.tsx b/app/react/portainer/environments/update-schedules/common/RollbackScheduleDetailsFieldset.tsx index 121756059..132430f43 100644 --- a/app/react/portainer/environments/update-schedules/common/RollbackScheduleDetailsFieldset.tsx +++ b/app/react/portainer/environments/update-schedules/common/RollbackScheduleDetailsFieldset.tsx @@ -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 (
- {environmentsCount > 0 ? ( -
-
- {environmentsCount} edge device(s) from {groupNames} will rollback - to their previous versions -
-
- ) : ( - - The are no rollback options available for yor selected groups(s) - - )} - +
); } - -function useSelectedEnvironmentsCount() { - const { - values: { environments }, - } = useFormikContext(); - - return Object.keys(environments).length; -} - -function useSelectEnvironmentsOnMount() { - const previousVersionsQuery = usePreviousVersions(); - - const { - values: { groupIds }, - setFieldValue, - } = useFormikContext(); - - 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(); - - 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(', '); -} diff --git a/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx b/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx index 4cd810cd9..9810b4ee4 100644 --- a/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx +++ b/app/react/portainer/environments/update-schedules/common/ScheduleTypeSelector.tsx @@ -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('type'); + const { values, setFieldValue } = useFormikContext(); return (
@@ -28,12 +28,17 @@ export function ScheduleTypeSelector() { children: , }, ]} - selectedId={value} - onSelect={(value) => setValue(value)} + selectedId={values.type} + onSelect={handleChangeType} />
); + + function handleChangeType(scheduleType: ScheduleType) { + setFieldValue('type', scheduleType); + setFieldValue('version', ''); + } } export function typeValidation() { diff --git a/app/react/portainer/environments/update-schedules/common/ScheduledTimeField.tsx b/app/react/portainer/environments/update-schedules/common/ScheduledTimeField.tsx index d2a8a52f0..0ee11f2d5 100644 --- a/app/react/portainer/environments/update-schedules/common/ScheduledTimeField.tsx +++ b/app/react/portainer/environments/update-schedules/common/ScheduledTimeField.tsx @@ -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('time'); + useField('scheduledTime'); - const dateValue = useMemo(() => new Date(value * 1000), [value]); + const dateValue = useMemo(() => parseIsoDate(value), [value]); return ( {!disabled ? ( setValue(Math.floor(date.getTime() / 1000))} + onChange={(date) => setValue(isoDate(date.valueOf()))} name={name} value={dateValue} calendarIcon={} clearIcon={} disableClock + minDate={new Date(Date.now() - 24 * 60 * 60 * 1000)} /> ) : ( - + )} ); } + +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()); +} diff --git a/app/react/portainer/environments/update-schedules/common/UpdateScheduleDetailsFieldset.tsx b/app/react/portainer/environments/update-schedules/common/UpdateScheduleDetailsFieldset.tsx index 72d760775..f76cc8062 100644 --- a/app/react/portainer/environments/update-schedules/common/UpdateScheduleDetailsFieldset.tsx +++ b/app/react/portainer/environments/update-schedules/common/UpdateScheduleDetailsFieldset.tsx @@ -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(); - 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(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 ( <> - + {edgeGroupsEnvironmentIds.length > 0 ? ( + !!values.version && ( + + {edgeGroupsEnvironmentIds.length} environment(s) will be updated to{' '} + {values.version} + + ) + ) : ( + + No environments options for the selected edge groups + + )} - + - + {hasTimeZone && } + {hasNoTimeZone && ( + + These edge groups have older versions of the edge agent that do not + support scheduling, these will happen immediately + + )} ); } @@ -48,17 +67,3 @@ function useEnvironments(environmentsIds: Array) { 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 - ) || [] - ); -} diff --git a/app/react/portainer/environments/update-schedules/common/VersionSelect.tsx b/app/react/portainer/environments/update-schedules/common/VersionSelect.tsx new file mode 100644 index 000000000..fa7134218 --- /dev/null +++ b/app/react/portainer/environments/update-schedules/common/VersionSelect.tsx @@ -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('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 ( + + No supported versions available + + ); + } + + const supportedVersions = supportedAgentVersionsQuery.data.map((version) => ({ + label: version, + value: version, + })); + + return ( + + + + ); +} diff --git a/app/react/portainer/environments/update-schedules/common/types.ts b/app/react/portainer/environments/update-schedules/common/types.ts index 30a8e9422..057c95a8d 100644 --- a/app/react/portainer/environments/update-schedules/common/types.ts +++ b/app/react/portainer/environments/update-schedules/common/types.ts @@ -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; + version: string; + scheduledTime: string; } diff --git a/app/react/portainer/environments/update-schedules/common/useEdgeGroupsEnvironmentIds.ts b/app/react/portainer/environments/update-schedules/common/useEdgeGroupsEnvironmentIds.ts index de9727071..6f44c7dab 100644 --- a/app/react/portainer/environments/update-schedules/common/useEdgeGroupsEnvironmentIds.ts +++ b/app/react/portainer/environments/update-schedules/common/useEdgeGroupsEnvironmentIds.ts @@ -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] + ); } diff --git a/app/react/portainer/environments/update-schedules/common/validation.ts b/app/react/portainer/environments/update-schedules/common/validation.ts index d3f30262c..10b2f18f3 100644 --- a/app/react/portainer/environments/update-schedules/common/validation.ts +++ b/app/react/portainer/environments/update-schedules/common/validation.ts @@ -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'), + }), }); } diff --git a/app/react/portainer/environments/update-schedules/queries/create.ts b/app/react/portainer/environments/update-schedules/queries/create.ts index 5ca763667..97b06fb6a 100644 --- a/app/react/portainer/environments/update-schedules/queries/create.ts +++ b/app/react/portainer/environments/update-schedules/queries/create.ts @@ -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(), }); } diff --git a/app/react/portainer/environments/update-schedules/queries/list.ts b/app/react/portainer/environments/update-schedules/queries/list.ts index b5d67f331..7b0f2a2cb 100644 --- a/app/react/portainer/environments/update-schedules/queries/list.ts +++ b/app/react/portainer/environments/update-schedules/queries/list.ts @@ -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(buildUrl()); + const { data } = await axios.get(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) + ); } diff --git a/app/react/portainer/environments/update-schedules/queries/query-keys.ts b/app/react/portainer/environments/update-schedules/queries/query-keys.ts index 345b2bc87..ebb0b5112 100644 --- a/app/react/portainer/environments/update-schedules/queries/query-keys.ts +++ b/app/react/portainer/environments/update-schedules/queries/query-keys.ts @@ -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, }; diff --git a/app/react/portainer/environments/update-schedules/queries/useItem.ts b/app/react/portainer/environments/update-schedules/queries/useItem.ts index 729631c24..ae3fb43b9 100644 --- a/app/react/portainer/environments/update-schedules/queries/useItem.ts +++ b/app/react/portainer/environments/update-schedules/queries/useItem.ts @@ -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(buildUrl(id)); + const { data } = await axios.get(buildUrl(id), { + params: { includeEdgeStack: true }, + }); return data; } catch (err) { throw parseAxiosError( diff --git a/app/react/portainer/environments/update-schedules/queries/usePreviousVersions.ts b/app/react/portainer/environments/update-schedules/queries/usePreviousVersions.ts index 87029d749..13ceb9173 100644 --- a/app/react/portainer/environments/update-schedules/queries/usePreviousVersions.ts +++ b/app/react/portainer/environments/update-schedules/queries/usePreviousVersions.ts @@ -6,11 +6,21 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { queryKeys } from './query-keys'; import { buildUrl } from './urls'; +interface Options { + select?: (data: Record) => T; + onSuccess?(data: T): void; + enabled?: boolean; +} + export function usePreviousVersions>({ select, -}: { select?: (data: Record) => T } = {}) { + onSuccess, + enabled, +}: Options = {}) { return useQuery(queryKeys.previousVersions(), getPreviousVersions, { select, + onSuccess, + enabled, }); } diff --git a/app/react/portainer/environments/update-schedules/queries/useRemoveMutation.ts b/app/react/portainer/environments/update-schedules/queries/useRemoveMutation.ts index 4b84a7000..74362e4ed 100644 --- a/app/react/portainer/environments/update-schedules/queries/useRemoveMutation.ts +++ b/app/react/portainer/environments/update-schedules/queries/useRemoveMutation.ts @@ -23,7 +23,7 @@ export function useRemoveMutation() { ), mutationOptions( - withInvalidate(queryClient, [queryKeys.list()]), + withInvalidate(queryClient, [queryKeys.base()]), withError() ) ); diff --git a/app/react/portainer/environments/update-schedules/queries/useSupportedAgentVersions.ts b/app/react/portainer/environments/update-schedules/queries/useSupportedAgentVersions.ts index bdf30743e..c8c9f3bb2 100644 --- a/app/react/portainer/environments/update-schedules/queries/useSupportedAgentVersions.ts +++ b/app/react/portainer/environments/update-schedules/queries/useSupportedAgentVersions.ts @@ -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({ - 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'), + } ); } diff --git a/app/react/portainer/environments/update-schedules/queries/useUpdateMutation.ts b/app/react/portainer/environments/update-schedules/queries/useUpdateMutation.ts index 60f07d22e..4e3f93aad 100644 --- a/app/react/portainer/environments/update-schedules/queries/useUpdateMutation.ts +++ b/app/react/portainer/environments/update-schedules/queries/useUpdateMutation.ts @@ -11,7 +11,7 @@ import { buildUrl } from './urls'; interface Update { id: EdgeUpdateSchedule['id']; - values: FormValues; + values: Partial; } 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(), }); } diff --git a/app/react/portainer/environments/update-schedules/types.ts b/app/react/portainer/environments/update-schedules/types.ts index 3d1606dc4..fb8566b9b 100644 --- a/app/react/portainer/environments/update-schedules/types.ts +++ b/app/react/portainer/environments/update-schedules/types.ts @@ -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; +}; + +export type EdgeUpdateResponse = EdgeUpdateSchedule & { + // from edge stack: + edgeGroupIds: EdgeGroup['Id'][]; + scheduledTime: string; }; diff --git a/app/react/portainer/feature-flags/useRedirectFeatureFlag.ts b/app/react/portainer/feature-flags/useRedirectFeatureFlag.ts index 0835864a5..fe4a5bcfe 100644 --- a/app/react/portainer/feature-flags/useRedirectFeatureFlag.ts +++ b/app/react/portainer/feature-flags/useRedirectFeatureFlag.ts @@ -3,7 +3,6 @@ import { useRouter } from '@uirouter/react'; import { usePublicSettings } from '@/react/portainer/settings/queries'; export enum FeatureFlag { - EdgeRemoteUpdate = 'edgeRemoteUpdate', BEUpgrade = 'beUpgrade', } diff --git a/app/react/sidebar/SettingsSidebar.tsx b/app/react/sidebar/SettingsSidebar.tsx index 2515d3a57..968135f0f 100644 --- a/app/react/sidebar/SettingsSidebar.tsx +++ b/app/react/sidebar/SettingsSidebar.tsx @@ -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 && ( - {process.env.PORTAINER_EDITION !== 'CE' && ( + {isBE && ( )} - {process.env.PORTAINER_EDITION !== 'CE' && ( + {isBE && (