mirror of https://github.com/portainer/portainer
feat(edge-compute): move host jobs to edge (#3840)
* feat(endpoints): create an associated endpoints selector * feat(schedules): remove edge specific explanations * refactor(schedule): replace multi-endpoint-selector * refactor(schedule): move controller to single file * refactor(endpoints): remove multi-endpoint-selector * feat(edge): rename host jobs to edge jobs * feat(edge-jobs): remove edge warning * refactor(edge-jobs): move schedule pages to edge * refactor(edge-jobs): mv views to edgeJobs * refactor(edge-jobs): rename edge jobs * refactor(edge-jobs): move services to edge * refactor(edge-jobs): move tasks datatable * fix(edge-jobs): fix import * fix(edge-jobs): use right services * feat(settings): adjust host management description * feat(edge-jobs): introduce interfaces and types * feat(edge-jobs): implement bolt service * refactor(edge-jobs): replace schedule routes * refactor(edge-job): replace Schedule service * refactor(edge-jobs): remove job_script_exec * refactor(host): remove jobs table * feat(edge-jobs): replace schedule * feat(edge-jobs): load file on inspect * fix(edge-job): parse cron correctly * feat(edge-jobs): show tasks * feat(host): rename tooltip * refactor(host): remove old components * refactor(main): remove schedule types * refactor(snapshot): replace job service with snapshot service * refactor(jobs): remove jobs form and datatable * feat(edge-jobs): create db migration * fix(main): start snapshot service with correct interval * feat(settings): change host tooltip * feat(edge-jobs): load endpoints * fix(edge-job): disable form submit when form is invalid * refactor(edge-compute): use const * refactor(edge-jobs): use generic controller * refactor(edge-jobs): replace $scope with controllerAs * refactor(edge-jobs): replace routes with components * refactor(edge-jobs): replace functions with classes * refactor(edge-jobs): use async/await * refactor(edge-jobs): rename functions * feat(edge-jobs): introduce beta panel * feat(edge-jobs): allow single character names * fix(snapshot): run snapshot in coroutine * feat(edge-jobs): add logs status * feat(filesystem): add edge job logs methods * feat(edge-jobs): intoduce edge jobs tasks api * feat(edge-jobs): remove schedule task model * fix(fs): build edge job task file path * fix(edge-jobs): update task meta * fix(edge-jobs): return a list of endpoints * feat(edge-jobs): update logs from agent * feat(edge-jobs): collect logs * feat(edge-jobs): rename url * feat(edge-jobs): refresh to same tab * feat(edge-jobs): remove old info * refactor(edge-jobs): rename script path json * fix(edge-job): save file before adding job * feat(edge-job): show retrieving logs label * feat(edge-job): replace cron with 5 places * refactor(edge-jobs): replace tasks with results * feat(edge-jobs): add auto refresh until logs are collected * feat(edge-jobs): fix column size * feat(edge-job): display editor * feat(edge-job): add name validation * feat(edge-job): set default time for 1 hour from now * feat(edge-job): add validation for cron format * feat(edge-job): add a note about timezone * fix(edge-job): replace regex * fix(edge-job): check for every minute cron * style(edge-jobs): add reference for cron regex * refactor(edge-jobs): rename migration name * refactor(edge-job): rename edge job response * refactor(snapshot): rename snapshot endpoint method * refactor(edge-jobs): move tasks handler to edgejobs * feat(security): introduce a middleware for edge compute operations * feat(edge-job): use edge compute middleware * feat(edge-groups): filter http actions based on edge setting * fix(security): return from edge bouncer if failed * feat(edge-stacks): filter http actions based on edge setting * feat(edge-groups): show error when failed to load groups * refactor(db): remove edge-jobs migration * refactor(migrator): remove unused dependency Co-authored-by: Anthony Lapenna <lapenna.anthony@gmail.com>pull/3987/head
parent
b6f5d8f90e
commit
24528ecea8
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/portainer/portainer/api"
|
"github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/bolt/dockerhub"
|
"github.com/portainer/portainer/api/bolt/dockerhub"
|
||||||
"github.com/portainer/portainer/api/bolt/edgegroup"
|
"github.com/portainer/portainer/api/bolt/edgegroup"
|
||||||
|
"github.com/portainer/portainer/api/bolt/edgejob"
|
||||||
"github.com/portainer/portainer/api/bolt/edgestack"
|
"github.com/portainer/portainer/api/bolt/edgestack"
|
||||||
"github.com/portainer/portainer/api/bolt/endpoint"
|
"github.com/portainer/portainer/api/bolt/endpoint"
|
||||||
"github.com/portainer/portainer/api/bolt/endpointgroup"
|
"github.com/portainer/portainer/api/bolt/endpointgroup"
|
||||||
|
@ -44,6 +45,7 @@ type Store struct {
|
||||||
fileService portainer.FileService
|
fileService portainer.FileService
|
||||||
DockerHubService *dockerhub.Service
|
DockerHubService *dockerhub.Service
|
||||||
EdgeGroupService *edgegroup.Service
|
EdgeGroupService *edgegroup.Service
|
||||||
|
EdgeJobService *edgejob.Service
|
||||||
EdgeStackService *edgestack.Service
|
EdgeStackService *edgestack.Service
|
||||||
EndpointGroupService *endpointgroup.Service
|
EndpointGroupService *endpointgroup.Service
|
||||||
EndpointService *endpoint.Service
|
EndpointService *endpoint.Service
|
||||||
|
@ -184,6 +186,12 @@ func (store *Store) initServices() error {
|
||||||
}
|
}
|
||||||
store.EdgeGroupService = edgeGroupService
|
store.EdgeGroupService = edgeGroupService
|
||||||
|
|
||||||
|
edgeJobService, err := edgejob.NewService(store.db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store.EdgeJobService = edgeJobService
|
||||||
|
|
||||||
endpointgroupService, err := endpointgroup.NewService(store.db)
|
endpointgroupService, err := endpointgroup.NewService(store.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -293,6 +301,11 @@ func (store *Store) EdgeGroup() portainer.EdgeGroupService {
|
||||||
return store.EdgeGroupService
|
return store.EdgeGroupService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EdgeJob gives access to the EdgeJob data management layer
|
||||||
|
func (store *Store) EdgeJob() portainer.EdgeJobService {
|
||||||
|
return store.EdgeJobService
|
||||||
|
}
|
||||||
|
|
||||||
// EdgeStack gives access to the EdgeStack data management layer
|
// EdgeStack gives access to the EdgeStack data management layer
|
||||||
func (store *Store) EdgeStack() portainer.EdgeStackService {
|
func (store *Store) EdgeStack() portainer.EdgeStackService {
|
||||||
return store.EdgeStackService
|
return store.EdgeStackService
|
||||||
|
@ -333,11 +346,6 @@ func (store *Store) Role() portainer.RoleService {
|
||||||
return store.RoleService
|
return store.RoleService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule gives access to the Schedule data management layer
|
|
||||||
func (store *Store) Schedule() portainer.ScheduleService {
|
|
||||||
return store.ScheduleService
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings gives access to the Settings data management layer
|
// Settings gives access to the Settings data management layer
|
||||||
func (store *Store) Settings() portainer.SettingsService {
|
func (store *Store) Settings() portainer.SettingsService {
|
||||||
return store.SettingsService
|
return store.SettingsService
|
||||||
|
|
|
@ -0,0 +1,101 @@
|
||||||
|
package edgejob
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/boltdb/bolt"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/bolt/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BucketName represents the name of the bucket where this service stores data.
|
||||||
|
BucketName = "edgejobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service represents a service for managing edge jobs data.
|
||||||
|
type Service struct {
|
||||||
|
db *bolt.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new instance of a service.
|
||||||
|
func NewService(db *bolt.DB) (*Service, error) {
|
||||||
|
err := internal.CreateBucket(db, BucketName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EdgeJobs returns a list of Edge jobs
|
||||||
|
func (service *Service) EdgeJobs() ([]portainer.EdgeJob, error) {
|
||||||
|
var edgeJobs = make([]portainer.EdgeJob, 0)
|
||||||
|
|
||||||
|
err := service.db.View(func(tx *bolt.Tx) error {
|
||||||
|
bucket := tx.Bucket([]byte(BucketName))
|
||||||
|
|
||||||
|
cursor := bucket.Cursor()
|
||||||
|
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||||
|
var edgeJob portainer.EdgeJob
|
||||||
|
err := internal.UnmarshalObject(v, &edgeJob)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
edgeJobs = append(edgeJobs, edgeJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return edgeJobs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EdgeJob returns an Edge job by ID
|
||||||
|
func (service *Service) EdgeJob(ID portainer.EdgeJobID) (*portainer.EdgeJob, error) {
|
||||||
|
var edgeJob portainer.EdgeJob
|
||||||
|
identifier := internal.Itob(int(ID))
|
||||||
|
|
||||||
|
err := internal.GetObject(service.db, BucketName, identifier, &edgeJob)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &edgeJob, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEdgeJob creates a new Edge job
|
||||||
|
func (service *Service) CreateEdgeJob(edgeJob *portainer.EdgeJob) error {
|
||||||
|
return service.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
bucket := tx.Bucket([]byte(BucketName))
|
||||||
|
|
||||||
|
if edgeJob.ID == 0 {
|
||||||
|
id, _ := bucket.NextSequence()
|
||||||
|
edgeJob.ID = portainer.EdgeJobID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := internal.MarshalObject(edgeJob)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket.Put(internal.Itob(int(edgeJob.ID)), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEdgeJob updates an Edge job by ID
|
||||||
|
func (service *Service) UpdateEdgeJob(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
|
||||||
|
identifier := internal.Itob(int(ID))
|
||||||
|
return internal.UpdateObject(service.db, BucketName, identifier, edgeJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEdgeJob deletes an Edge job
|
||||||
|
func (service *Service) DeleteEdgeJob(ID portainer.EdgeJobID) error {
|
||||||
|
identifier := internal.Itob(int(ID))
|
||||||
|
return internal.DeleteObject(service.db, BucketName, identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNextIdentifier returns the next identifier for an endpoint.
|
||||||
|
func (service *Service) GetNextIdentifier() int {
|
||||||
|
return internal.GetNextIdentifier(service.db, BucketName)
|
||||||
|
}
|
|
@ -2,10 +2,10 @@ package migrator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const scheduleScriptExecutionJobType = 1
|
||||||
|
|
||||||
func (m *Migrator) updateUsersToDBVersion20() error {
|
func (m *Migrator) updateUsersToDBVersion20() error {
|
||||||
return m.authorizationService.UpdateUsersAuthorizations()
|
return m.authorizationService.UpdateUsersAuthorizations()
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@ func (m *Migrator) updateSchedulesToDBVersion20() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, schedule := range legacySchedules {
|
for _, schedule := range legacySchedules {
|
||||||
if schedule.JobType == portainer.ScriptExecutionJobType {
|
if schedule.JobType == scheduleScriptExecutionJobType {
|
||||||
if schedule.CronExpression == "0 0 * * *" {
|
if schedule.CronExpression == "0 0 * * *" {
|
||||||
schedule.CronExpression = "0 * * * *"
|
schedule.CronExpression = "0 * * * *"
|
||||||
} else if schedule.CronExpression == "0 0 0/2 * *" {
|
} else if schedule.CronExpression == "0 0 0/2 * *" {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package migrator
|
package migrator
|
||||||
|
|
||||||
import portainer "github.com/portainer/portainer/api"
|
import (
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
func (m *Migrator) updateSettingsToDB24() error {
|
func (m *Migrator) updateSettingsToDB24() error {
|
||||||
legacySettings, err := m.settingsService.Settings()
|
legacySettings, err := m.settingsService.Settings()
|
||||||
|
|
|
@ -6,42 +6,42 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddSchedule register a schedule inside the tunnel details associated to an endpoint.
|
// AddEdgeJob register an EdgeJob inside the tunnel details associated to an endpoint.
|
||||||
func (service *Service) AddSchedule(endpointID portainer.EndpointID, schedule *portainer.EdgeSchedule) {
|
func (service *Service) AddEdgeJob(endpointID portainer.EndpointID, edgeJob *portainer.EdgeJob) {
|
||||||
tunnel := service.GetTunnelDetails(endpointID)
|
tunnel := service.GetTunnelDetails(endpointID)
|
||||||
|
|
||||||
existingScheduleIndex := -1
|
existingJobIndex := -1
|
||||||
for idx, existingSchedule := range tunnel.Schedules {
|
for idx, existingJob := range tunnel.Jobs {
|
||||||
if existingSchedule.ID == schedule.ID {
|
if existingJob.ID == edgeJob.ID {
|
||||||
existingScheduleIndex = idx
|
existingJobIndex = idx
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if existingScheduleIndex == -1 {
|
if existingJobIndex == -1 {
|
||||||
tunnel.Schedules = append(tunnel.Schedules, *schedule)
|
tunnel.Jobs = append(tunnel.Jobs, *edgeJob)
|
||||||
} else {
|
} else {
|
||||||
tunnel.Schedules[existingScheduleIndex] = *schedule
|
tunnel.Jobs[existingJobIndex] = *edgeJob
|
||||||
}
|
}
|
||||||
|
|
||||||
key := strconv.Itoa(int(endpointID))
|
key := strconv.Itoa(int(endpointID))
|
||||||
service.tunnelDetailsMap.Set(key, tunnel)
|
service.tunnelDetailsMap.Set(key, tunnel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveSchedule will remove the specified schedule from each tunnel it was registered with.
|
// RemoveEdgeJob will remove the specified Edge job from each tunnel it was registered with.
|
||||||
func (service *Service) RemoveSchedule(scheduleID portainer.ScheduleID) {
|
func (service *Service) RemoveEdgeJob(edgeJobID portainer.EdgeJobID) {
|
||||||
for item := range service.tunnelDetailsMap.IterBuffered() {
|
for item := range service.tunnelDetailsMap.IterBuffered() {
|
||||||
tunnelDetails := item.Val.(*portainer.TunnelDetails)
|
tunnelDetails := item.Val.(*portainer.TunnelDetails)
|
||||||
|
|
||||||
updatedSchedules := make([]portainer.EdgeSchedule, 0)
|
updatedJobs := make([]portainer.EdgeJob, 0)
|
||||||
for _, schedule := range tunnelDetails.Schedules {
|
for _, edgeJob := range tunnelDetails.Jobs {
|
||||||
if schedule.ID == scheduleID {
|
if edgeJob.ID == edgeJobID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
updatedSchedules = append(updatedSchedules, schedule)
|
updatedJobs = append(updatedJobs, edgeJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
tunnelDetails.Schedules = updatedSchedules
|
tunnelDetails.Jobs = updatedJobs
|
||||||
service.tunnelDetailsMap.Set(item.Key, tunnelDetails)
|
service.tunnelDetailsMap.Set(item.Key, tunnelDetails)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -155,7 +155,7 @@ func (service *Service) checkTunnels() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tunnel.Schedules) > 0 {
|
if len(tunnel.Jobs) > 0 {
|
||||||
endpointID, err := strconv.Atoi(item.Key)
|
endpointID, err := strconv.Atoi(item.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ERROR] [chisel,conversion] Invalid endpoint identifier (id: %s): %s", item.Key, err)
|
log.Printf("[ERROR] [chisel,conversion] Invalid endpoint identifier (id: %s): %s", item.Key, err)
|
||||||
|
|
|
@ -47,11 +47,11 @@ func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) *porta
|
||||||
return tunnelDetails
|
return tunnelDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
schedules := make([]portainer.EdgeSchedule, 0)
|
jobs := make([]portainer.EdgeJob, 0)
|
||||||
return &portainer.TunnelDetails{
|
return &portainer.TunnelDetails{
|
||||||
Status: portainer.EdgeAgentIdle,
|
Status: portainer.EdgeAgentIdle,
|
||||||
Port: 0,
|
Port: 0,
|
||||||
Schedules: schedules,
|
Jobs: jobs,
|
||||||
Credentials: "",
|
Credentials: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,11 +8,11 @@ import (
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/chisel"
|
"github.com/portainer/portainer/api/chisel"
|
||||||
"github.com/portainer/portainer/api/internal/authorization"
|
"github.com/portainer/portainer/api/internal/authorization"
|
||||||
|
"github.com/portainer/portainer/api/internal/snapshot"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api"
|
"github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/bolt"
|
"github.com/portainer/portainer/api/bolt"
|
||||||
"github.com/portainer/portainer/api/cli"
|
"github.com/portainer/portainer/api/cli"
|
||||||
"github.com/portainer/portainer/api/cron"
|
|
||||||
"github.com/portainer/portainer/api/crypto"
|
"github.com/portainer/portainer/api/crypto"
|
||||||
"github.com/portainer/portainer/api/docker"
|
"github.com/portainer/portainer/api/docker"
|
||||||
"github.com/portainer/portainer/api/exec"
|
"github.com/portainer/portainer/api/exec"
|
||||||
|
@ -115,75 +115,16 @@ func initSnapshotter(clientFactory *docker.ClientFactory) portainer.Snapshotter
|
||||||
return docker.NewSnapshotter(clientFactory)
|
return docker.NewSnapshotter(clientFactory)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initJobScheduler() portainer.JobScheduler {
|
func loadEdgeJobsFromDatabase(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService) error {
|
||||||
return cron.NewJobScheduler()
|
edgeJobs, err := dataStore.EdgeJob().EdgeJobs()
|
||||||
}
|
|
||||||
|
|
||||||
func loadSnapshotSystemSchedule(jobScheduler portainer.JobScheduler, snapshotter portainer.Snapshotter, dataStore portainer.DataStore) error {
|
|
||||||
settings, err := dataStore.Settings().Settings()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
schedules, err := dataStore.Schedule().SchedulesByJobType(portainer.SnapshotJobType)
|
for _, edgeJob := range edgeJobs {
|
||||||
if err != nil {
|
for endpointID := range edgeJob.Endpoints {
|
||||||
return err
|
reverseTunnelService.AddEdgeJob(endpointID, &edgeJob)
|
||||||
}
|
|
||||||
|
|
||||||
var snapshotSchedule *portainer.Schedule
|
|
||||||
if len(schedules) == 0 {
|
|
||||||
snapshotJob := &portainer.SnapshotJob{}
|
|
||||||
snapshotSchedule = &portainer.Schedule{
|
|
||||||
ID: portainer.ScheduleID(dataStore.Schedule().GetNextIdentifier()),
|
|
||||||
Name: "system_snapshot",
|
|
||||||
CronExpression: "@every " + settings.SnapshotInterval,
|
|
||||||
Recurring: true,
|
|
||||||
JobType: portainer.SnapshotJobType,
|
|
||||||
SnapshotJob: snapshotJob,
|
|
||||||
Created: time.Now().Unix(),
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
snapshotSchedule = &schedules[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshotJobContext := cron.NewSnapshotJobContext(dataStore, snapshotter)
|
|
||||||
snapshotJobRunner := cron.NewSnapshotJobRunner(snapshotSchedule, snapshotJobContext)
|
|
||||||
|
|
||||||
err = jobScheduler.ScheduleJob(snapshotJobRunner)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(schedules) == 0 {
|
|
||||||
return dataStore.Schedule().CreateSchedule(snapshotSchedule)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService portainer.JobService, dataStore portainer.DataStore, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) error {
|
|
||||||
schedules, err := dataStore.Schedule().Schedules()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, schedule := range schedules {
|
|
||||||
|
|
||||||
if schedule.JobType == portainer.ScriptExecutionJobType {
|
|
||||||
jobContext := cron.NewScriptExecutionJobContext(jobService, dataStore, fileService)
|
|
||||||
jobRunner := cron.NewScriptExecutionJobRunner(&schedule, jobContext)
|
|
||||||
|
|
||||||
err = jobScheduler.ScheduleJob(jobRunner)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if schedule.EdgeSchedule != nil {
|
|
||||||
for _, endpointID := range schedule.EdgeSchedule.Endpoints {
|
|
||||||
reverseTunnelService.AddSchedule(endpointID, schedule.EdgeSchedule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -357,10 +298,6 @@ func initEndpoint(flags *portainer.CLIFlags, dataStore portainer.DataStore, snap
|
||||||
return createUnsecuredEndpoint(*flags.EndpointURL, dataStore, snapshotter)
|
return createUnsecuredEndpoint(*flags.EndpointURL, dataStore, snapshotter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initJobService(dockerClientFactory *docker.ClientFactory) portainer.JobService {
|
|
||||||
return docker.NewJobService(dockerClientFactory)
|
|
||||||
}
|
|
||||||
|
|
||||||
func initExtensionManager(fileService portainer.FileService, dataStore portainer.DataStore) (portainer.ExtensionManager, error) {
|
func initExtensionManager(fileService portainer.FileService, dataStore portainer.DataStore) (portainer.ExtensionManager, error) {
|
||||||
extensionManager := exec.NewExtensionManager(fileService, dataStore)
|
extensionManager := exec.NewExtensionManager(fileService, dataStore)
|
||||||
|
|
||||||
|
@ -422,10 +359,14 @@ func main() {
|
||||||
|
|
||||||
clientFactory := initClientFactory(digitalSignatureService, reverseTunnelService)
|
clientFactory := initClientFactory(digitalSignatureService, reverseTunnelService)
|
||||||
|
|
||||||
jobService := initJobService(clientFactory)
|
|
||||||
|
|
||||||
snapshotter := initSnapshotter(clientFactory)
|
snapshotter := initSnapshotter(clientFactory)
|
||||||
|
|
||||||
|
snapshotService, err := snapshot.NewService(*flags.SnapshotInterval, dataStore, snapshotter)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshotService.Start()
|
||||||
|
|
||||||
swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService, reverseTunnelService)
|
swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService, reverseTunnelService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
@ -440,20 +381,11 @@ func main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jobScheduler := initJobScheduler()
|
err = loadEdgeJobsFromDatabase(dataStore, reverseTunnelService)
|
||||||
|
|
||||||
err = loadSchedulesFromDatabase(jobScheduler, jobService, dataStore, fileService, reverseTunnelService)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = loadSnapshotSystemSchedule(jobScheduler, snapshotter, dataStore)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
jobScheduler.Start()
|
|
||||||
|
|
||||||
applicationStatus := initStatus(flags)
|
applicationStatus := initStatus(flags)
|
||||||
|
|
||||||
err = initEndpoint(flags, dataStore, snapshotter)
|
err = initEndpoint(flags, dataStore, snapshotter)
|
||||||
|
@ -520,13 +452,12 @@ func main() {
|
||||||
LDAPService: ldapService,
|
LDAPService: ldapService,
|
||||||
GitService: gitService,
|
GitService: gitService,
|
||||||
SignatureService: digitalSignatureService,
|
SignatureService: digitalSignatureService,
|
||||||
JobScheduler: jobScheduler,
|
SnapshotService: snapshotService,
|
||||||
Snapshotter: snapshotter,
|
Snapshotter: snapshotter,
|
||||||
SSL: *flags.SSL,
|
SSL: *flags.SSL,
|
||||||
SSLCert: *flags.SSLCert,
|
SSLCert: *flags.SSLCert,
|
||||||
SSLKey: *flags.SSLKey,
|
SSLKey: *flags.SSLKey,
|
||||||
DockerClientFactory: clientFactory,
|
DockerClientFactory: clientFactory,
|
||||||
JobService: jobService,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr)
|
log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr)
|
||||||
|
|
|
@ -1,96 +0,0 @@
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ScriptExecutionJobRunner is used to run a ScriptExecutionJob
|
|
||||||
type ScriptExecutionJobRunner struct {
|
|
||||||
schedule *portainer.Schedule
|
|
||||||
context *ScriptExecutionJobContext
|
|
||||||
executedOnce bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScriptExecutionJobContext represents the context of execution of a ScriptExecutionJob
|
|
||||||
type ScriptExecutionJobContext struct {
|
|
||||||
dataStore portainer.DataStore
|
|
||||||
jobService portainer.JobService
|
|
||||||
fileService portainer.FileService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewScriptExecutionJobContext returns a new context that can be used to execute a ScriptExecutionJob
|
|
||||||
func NewScriptExecutionJobContext(jobService portainer.JobService, dataStore portainer.DataStore, fileService portainer.FileService) *ScriptExecutionJobContext {
|
|
||||||
return &ScriptExecutionJobContext{
|
|
||||||
jobService: jobService,
|
|
||||||
dataStore: dataStore,
|
|
||||||
fileService: fileService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewScriptExecutionJobRunner returns a new runner that can be scheduled
|
|
||||||
func NewScriptExecutionJobRunner(schedule *portainer.Schedule, context *ScriptExecutionJobContext) *ScriptExecutionJobRunner {
|
|
||||||
return &ScriptExecutionJobRunner{
|
|
||||||
schedule: schedule,
|
|
||||||
context: context,
|
|
||||||
executedOnce: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run triggers the execution of the job.
|
|
||||||
// It will iterate through all the endpoints specified in the context to
|
|
||||||
// execute the script associated to the job.
|
|
||||||
func (runner *ScriptExecutionJobRunner) Run() {
|
|
||||||
if !runner.schedule.Recurring && runner.executedOnce {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runner.executedOnce = true
|
|
||||||
|
|
||||||
scriptFile, err := runner.context.fileService.GetFileContent(runner.schedule.ScriptExecutionJob.ScriptPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("scheduled job error (script execution). Unable to retrieve script file (err=%s)\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targets := make([]*portainer.Endpoint, 0)
|
|
||||||
for _, endpointID := range runner.schedule.ScriptExecutionJob.Endpoints {
|
|
||||||
endpoint, err := runner.context.dataStore.Endpoint().Endpoint(endpointID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("scheduled job error (script execution). Unable to retrieve information about endpoint (id=%d) (err=%s)\n", endpointID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targets = append(targets, endpoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
runner.executeAndRetry(targets, scriptFile, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (runner *ScriptExecutionJobRunner) executeAndRetry(endpoints []*portainer.Endpoint, script []byte, retryCount int) {
|
|
||||||
retryTargets := make([]*portainer.Endpoint, 0)
|
|
||||||
|
|
||||||
for _, endpoint := range endpoints {
|
|
||||||
err := runner.context.jobService.ExecuteScript(endpoint, "", runner.schedule.ScriptExecutionJob.Image, script, runner.schedule)
|
|
||||||
if err == portainer.ErrUnableToPingEndpoint {
|
|
||||||
retryTargets = append(retryTargets, endpoint)
|
|
||||||
} else if err != nil {
|
|
||||||
log.Printf("scheduled job error (script execution). Unable to execute script (endpoint=%s) (err=%s)\n", endpoint.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
retryCount++
|
|
||||||
if retryCount >= runner.schedule.ScriptExecutionJob.RetryCount {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(time.Duration(runner.schedule.ScriptExecutionJob.RetryInterval) * time.Second)
|
|
||||||
|
|
||||||
runner.executeAndRetry(retryTargets, script, retryCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSchedule returns the schedule associated to the runner
|
|
||||||
func (runner *ScriptExecutionJobRunner) GetSchedule() *portainer.Schedule {
|
|
||||||
return runner.schedule
|
|
||||||
}
|
|
|
@ -1,85 +0,0 @@
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SnapshotJobRunner is used to run a SnapshotJob
|
|
||||||
type SnapshotJobRunner struct {
|
|
||||||
schedule *portainer.Schedule
|
|
||||||
context *SnapshotJobContext
|
|
||||||
}
|
|
||||||
|
|
||||||
// SnapshotJobContext represents the context of execution of a SnapshotJob
|
|
||||||
type SnapshotJobContext struct {
|
|
||||||
dataStore portainer.DataStore
|
|
||||||
snapshotter portainer.Snapshotter
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSnapshotJobContext returns a new context that can be used to execute a SnapshotJob
|
|
||||||
func NewSnapshotJobContext(dataStore portainer.DataStore, snapshotter portainer.Snapshotter) *SnapshotJobContext {
|
|
||||||
return &SnapshotJobContext{
|
|
||||||
dataStore: dataStore,
|
|
||||||
snapshotter: snapshotter,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSnapshotJobRunner returns a new runner that can be scheduled
|
|
||||||
func NewSnapshotJobRunner(schedule *portainer.Schedule, context *SnapshotJobContext) *SnapshotJobRunner {
|
|
||||||
return &SnapshotJobRunner{
|
|
||||||
schedule: schedule,
|
|
||||||
context: context,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSchedule returns the schedule associated to the runner
|
|
||||||
func (runner *SnapshotJobRunner) GetSchedule() *portainer.Schedule {
|
|
||||||
return runner.schedule
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run triggers the execution of the schedule.
|
|
||||||
// It will iterate through all the endpoints available in the database to
|
|
||||||
// create a snapshot of each one of them.
|
|
||||||
// As a snapshot can be a long process, to avoid any concurrency issue we
|
|
||||||
// retrieve the latest version of the endpoint right after a snapshot.
|
|
||||||
func (runner *SnapshotJobRunner) Run() {
|
|
||||||
go func() {
|
|
||||||
endpoints, err := runner.context.dataStore.Endpoint().Endpoints()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("background schedule error (endpoint snapshot). Unable to retrieve endpoint list (err=%s)\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, endpoint := range endpoints {
|
|
||||||
if endpoint.Type == portainer.AzureEnvironment || endpoint.Type == portainer.EdgeAgentEnvironment {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot, snapshotError := runner.context.snapshotter.CreateSnapshot(&endpoint)
|
|
||||||
|
|
||||||
latestEndpointReference, err := runner.context.dataStore.Endpoint().Endpoint(endpoint.ID)
|
|
||||||
if latestEndpointReference == nil {
|
|
||||||
log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
latestEndpointReference.Status = portainer.EndpointStatusUp
|
|
||||||
if snapshotError != nil {
|
|
||||||
log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError)
|
|
||||||
latestEndpointReference.Status = portainer.EndpointStatusDown
|
|
||||||
}
|
|
||||||
|
|
||||||
if snapshot != nil {
|
|
||||||
latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = runner.context.dataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("background schedule error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
|
@ -1,116 +0,0 @@
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JobScheduler represents a service for managing crons
|
|
||||||
type JobScheduler struct {
|
|
||||||
cron *cron.Cron
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewJobScheduler initializes a new service
|
|
||||||
func NewJobScheduler() *JobScheduler {
|
|
||||||
return &JobScheduler{
|
|
||||||
cron: cron.New(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScheduleJob schedules the execution of a job via a runner
|
|
||||||
func (scheduler *JobScheduler) ScheduleJob(runner portainer.JobRunner) error {
|
|
||||||
_, err := scheduler.cron.AddJob(runner.GetSchedule().CronExpression, runner)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSystemJobSchedule updates the first occurence of the specified
|
|
||||||
// scheduled job based on the specified job type.
|
|
||||||
// It does so by re-creating a new cron
|
|
||||||
// and adding all the existing jobs. It will then re-schedule the new job
|
|
||||||
// with the update cron expression passed in parameter.
|
|
||||||
// NOTE: the cron library do not support updating schedules directly
|
|
||||||
// hence the work-around
|
|
||||||
func (scheduler *JobScheduler) UpdateSystemJobSchedule(jobType portainer.JobType, newCronExpression string) error {
|
|
||||||
cronEntries := scheduler.cron.Entries()
|
|
||||||
newCron := cron.New()
|
|
||||||
|
|
||||||
for _, entry := range cronEntries {
|
|
||||||
if entry.Job.(portainer.JobRunner).GetSchedule().JobType == jobType {
|
|
||||||
_, err := newCron.AddJob(newCronExpression, entry.Job)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
newCron.Schedule(entry.Schedule, entry.Job)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduler.cron.Stop()
|
|
||||||
scheduler.cron = newCron
|
|
||||||
scheduler.cron.Start()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateJobSchedule updates a specific scheduled job by re-creating a new cron
|
|
||||||
// and adding all the existing jobs. It will then re-schedule the new job
|
|
||||||
// via the specified JobRunner parameter.
|
|
||||||
// NOTE: the cron library do not support updating schedules directly
|
|
||||||
// hence the work-around
|
|
||||||
func (scheduler *JobScheduler) UpdateJobSchedule(runner portainer.JobRunner) error {
|
|
||||||
cronEntries := scheduler.cron.Entries()
|
|
||||||
newCron := cron.New()
|
|
||||||
|
|
||||||
for _, entry := range cronEntries {
|
|
||||||
|
|
||||||
if entry.Job.(portainer.JobRunner).GetSchedule().ID == runner.GetSchedule().ID {
|
|
||||||
|
|
||||||
var jobRunner cron.Job = runner
|
|
||||||
if entry.Job.(portainer.JobRunner).GetSchedule().JobType == portainer.SnapshotJobType {
|
|
||||||
jobRunner = entry.Job
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := newCron.AddJob(runner.GetSchedule().CronExpression, jobRunner)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
newCron.Schedule(entry.Schedule, entry.Job)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduler.cron.Stop()
|
|
||||||
scheduler.cron = newCron
|
|
||||||
scheduler.cron.Start()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnscheduleJob remove a scheduled job by re-creating a new cron
|
|
||||||
// and adding all the existing jobs except for the one specified via scheduleID.
|
|
||||||
// NOTE: the cron library do not support removing schedules directly
|
|
||||||
// hence the work-around
|
|
||||||
func (scheduler *JobScheduler) UnscheduleJob(scheduleID portainer.ScheduleID) {
|
|
||||||
cronEntries := scheduler.cron.Entries()
|
|
||||||
newCron := cron.New()
|
|
||||||
|
|
||||||
for _, entry := range cronEntries {
|
|
||||||
|
|
||||||
if entry.Job.(portainer.JobRunner).GetSchedule().ID == scheduleID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
newCron.Schedule(entry.Schedule, entry.Job)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduler.cron.Stop()
|
|
||||||
scheduler.cron = newCron
|
|
||||||
scheduler.cron.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start starts the scheduled jobs
|
|
||||||
func (scheduler *JobScheduler) Start() {
|
|
||||||
if len(scheduler.cron.Entries()) > 0 {
|
|
||||||
scheduler.cron.Start()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,115 +0,0 @@
|
||||||
package docker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
|
||||||
"github.com/docker/docker/api/types/network"
|
|
||||||
"github.com/docker/docker/api/types/strslice"
|
|
||||||
"github.com/docker/docker/client"
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/archive"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JobService represents a service that handles the execution of jobs
|
|
||||||
type JobService struct {
|
|
||||||
dockerClientFactory *ClientFactory
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewJobService returns a pointer to a new job service
|
|
||||||
func NewJobService(dockerClientFactory *ClientFactory) *JobService {
|
|
||||||
return &JobService{
|
|
||||||
dockerClientFactory: dockerClientFactory,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExecuteScript will leverage a privileged container to execute a script against the specified endpoint/nodename.
|
|
||||||
// It will copy the script content specified as a parameter inside a container based on the specified image and execute it.
|
|
||||||
func (service *JobService) ExecuteScript(endpoint *portainer.Endpoint, nodeName, image string, script []byte, schedule *portainer.Schedule) error {
|
|
||||||
buffer, err := archive.TarFileInBuffer(script, "script.sh", 0700)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cli, err := service.dockerClientFactory.CreateClient(endpoint, nodeName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cli.Close()
|
|
||||||
|
|
||||||
_, err = cli.Ping(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return portainer.ErrUnableToPingEndpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
err = pullImage(cli, image)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
containerConfig := &container.Config{
|
|
||||||
AttachStdin: true,
|
|
||||||
AttachStdout: true,
|
|
||||||
AttachStderr: true,
|
|
||||||
Tty: true,
|
|
||||||
WorkingDir: "/tmp",
|
|
||||||
Image: image,
|
|
||||||
Labels: map[string]string{
|
|
||||||
"io.portainer.job.endpoint": strconv.Itoa(int(endpoint.ID)),
|
|
||||||
},
|
|
||||||
Cmd: strslice.StrSlice([]string{"sh", "/tmp/script.sh"}),
|
|
||||||
}
|
|
||||||
|
|
||||||
if schedule != nil {
|
|
||||||
containerConfig.Labels["io.portainer.schedule.id"] = strconv.Itoa(int(schedule.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
hostConfig := &container.HostConfig{
|
|
||||||
Binds: []string{"/:/host", "/etc:/etc:ro", "/usr:/usr:ro", "/run:/run:ro", "/sbin:/sbin:ro", "/var:/var:ro"},
|
|
||||||
NetworkMode: "host",
|
|
||||||
Privileged: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
networkConfig := &network.NetworkingConfig{}
|
|
||||||
|
|
||||||
body, err := cli.ContainerCreate(context.Background(), containerConfig, hostConfig, networkConfig, "")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if schedule != nil {
|
|
||||||
err = cli.ContainerRename(context.Background(), body.ID, schedule.Name+"_"+body.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
copyOptions := types.CopyToContainerOptions{}
|
|
||||||
err = cli.CopyToContainer(context.Background(), body.ID, "/tmp", bytes.NewReader(buffer), copyOptions)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
startOptions := types.ContainerStartOptions{}
|
|
||||||
return cli.ContainerStart(context.Background(), body.ID, startOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pullImage(cli *client.Client, image string) error {
|
|
||||||
imageReadCloser, err := cli.ImagePull(context.Background(), image, types.ImagePullOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer imageReadCloser.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(ioutil.Discard, imageReadCloser)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api"
|
"github.com/portainer/portainer/api"
|
||||||
|
@ -37,8 +38,8 @@ const (
|
||||||
PublicKeyFile = "portainer.pub"
|
PublicKeyFile = "portainer.pub"
|
||||||
// BinaryStorePath represents the subfolder where binaries are stored in the file store folder.
|
// BinaryStorePath represents the subfolder where binaries are stored in the file store folder.
|
||||||
BinaryStorePath = "bin"
|
BinaryStorePath = "bin"
|
||||||
// ScheduleStorePath represents the subfolder where schedule files are stored.
|
// EdgeJobStorePath represents the subfolder where schedule files are stored.
|
||||||
ScheduleStorePath = "schedules"
|
EdgeJobStorePath = "edge_jobs"
|
||||||
// ExtensionRegistryManagementStorePath represents the subfolder where files related to the
|
// ExtensionRegistryManagementStorePath represents the subfolder where files related to the
|
||||||
// registry management extension are stored.
|
// registry management extension are stored.
|
||||||
ExtensionRegistryManagementStorePath = "extensions"
|
ExtensionRegistryManagementStorePath = "extensions"
|
||||||
|
@ -392,22 +393,22 @@ func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) {
|
||||||
return block.Bytes, nil
|
return block.Bytes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetScheduleFolder returns the absolute path on the filesystem for a schedule based
|
// GetEdgeJobFolder returns the absolute path on the filesystem for an Edge job based
|
||||||
// on its identifier.
|
// on its identifier.
|
||||||
func (service *Service) GetScheduleFolder(identifier string) string {
|
func (service *Service) GetEdgeJobFolder(identifier string) string {
|
||||||
return path.Join(service.fileStorePath, ScheduleStorePath, identifier)
|
return path.Join(service.fileStorePath, EdgeJobStorePath, identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreScheduledJobFileFromBytes creates a subfolder in the ScheduleStorePath and stores a new file from bytes.
|
// StoreEdgeJobFileFromBytes creates a subfolder in the EdgeJobStorePath and stores a new file from bytes.
|
||||||
// It returns the path to the folder where the file is stored.
|
// It returns the path to the folder where the file is stored.
|
||||||
func (service *Service) StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error) {
|
func (service *Service) StoreEdgeJobFileFromBytes(identifier string, data []byte) (string, error) {
|
||||||
scheduleStorePath := path.Join(ScheduleStorePath, identifier)
|
edgeJobStorePath := path.Join(EdgeJobStorePath, identifier)
|
||||||
err := service.createDirectoryInStore(scheduleStorePath)
|
err := service.createDirectoryInStore(edgeJobStorePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := path.Join(scheduleStorePath, createScheduledJobFileName(identifier))
|
filePath := path.Join(edgeJobStorePath, createEdgeJobFileName(identifier))
|
||||||
r := bytes.NewReader(data)
|
r := bytes.NewReader(data)
|
||||||
err = service.createFileInStore(filePath, r)
|
err = service.createFileInStore(filePath, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -417,6 +418,52 @@ func (service *Service) StoreScheduledJobFileFromBytes(identifier string, data [
|
||||||
return path.Join(service.fileStorePath, filePath), nil
|
return path.Join(service.fileStorePath, filePath), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createScheduledJobFileName(identifier string) string {
|
func createEdgeJobFileName(identifier string) string {
|
||||||
return "job_" + identifier + ".sh"
|
return "job_" + identifier + ".sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearEdgeJobTaskLogs clears the Edge job task logs
|
||||||
|
func (service *Service) ClearEdgeJobTaskLogs(edgeJobID string, taskID string) error {
|
||||||
|
path := service.getEdgeJobTaskLogPath(edgeJobID, taskID)
|
||||||
|
|
||||||
|
err := os.Remove(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEdgeJobTaskLogFileContent fetches the Edge job task logs
|
||||||
|
func (service *Service) GetEdgeJobTaskLogFileContent(edgeJobID string, taskID string) (string, error) {
|
||||||
|
path := service.getEdgeJobTaskLogPath(edgeJobID, taskID)
|
||||||
|
|
||||||
|
fileContent, err := ioutil.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(fileContent), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreEdgeJobTaskLogFileFromBytes stores the log file
|
||||||
|
func (service *Service) StoreEdgeJobTaskLogFileFromBytes(edgeJobID, taskID string, data []byte) error {
|
||||||
|
edgeJobStorePath := path.Join(EdgeJobStorePath, edgeJobID)
|
||||||
|
err := service.createDirectoryInStore(edgeJobStorePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := path.Join(edgeJobStorePath, fmt.Sprintf("logs_%s", taskID))
|
||||||
|
r := bytes.NewReader(data)
|
||||||
|
err = service.createFileInStore(filePath, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) getEdgeJobTaskLogPath(edgeJobID string, taskID string) string {
|
||||||
|
return fmt.Sprintf("%s/logs_%s", service.GetEdgeJobFolder(edgeJobID), taskID)
|
||||||
|
}
|
||||||
|
|
|
@ -28,7 +28,6 @@ require (
|
||||||
github.com/portainer/libcompose v0.5.3
|
github.com/portainer/libcompose v0.5.3
|
||||||
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2
|
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2
|
||||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
|
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
|
||||||
github.com/robfig/cron/v3 v3.0.0
|
|
||||||
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1
|
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1
|
||||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||||
gopkg.in/src-d/go-git.v4 v4.13.1
|
gopkg.in/src-d/go-git.v4 v4.13.1
|
||||||
|
|
|
@ -185,8 +185,6 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R
|
||||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||||
github.com/prometheus/procfs v0.0.3 h1:CTwfnzjQ+8dS6MhHHu4YswVAD99sL2wjPqP+VkURmKE=
|
github.com/prometheus/procfs v0.0.3 h1:CTwfnzjQ+8dS6MhHHu4YswVAD99sL2wjPqP+VkURmKE=
|
||||||
github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
|
github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
|
||||||
github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E=
|
|
||||||
github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
|
||||||
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||||
github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo=
|
github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo=
|
||||||
|
|
|
@ -21,14 +21,14 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
}
|
}
|
||||||
h.Handle("/edge_groups",
|
h.Handle("/edge_groups",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupCreate))).Methods(http.MethodPost)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupCreate)))).Methods(http.MethodPost)
|
||||||
h.Handle("/edge_groups",
|
h.Handle("/edge_groups",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupList))).Methods(http.MethodGet)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupList)))).Methods(http.MethodGet)
|
||||||
h.Handle("/edge_groups/{id}",
|
h.Handle("/edge_groups/{id}",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupInspect))).Methods(http.MethodGet)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupInspect)))).Methods(http.MethodGet)
|
||||||
h.Handle("/edge_groups/{id}",
|
h.Handle("/edge_groups/{id}",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupUpdate))).Methods(http.MethodPut)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupUpdate)))).Methods(http.MethodPut)
|
||||||
h.Handle("/edge_groups/{id}",
|
h.Handle("/edge_groups/{id}",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeGroupDelete))).Methods(http.MethodDelete)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupDelete)))).Methods(http.MethodDelete)
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,220 @@
|
||||||
|
package edgejobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/asaskevich/govalidator"
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/edge_jobs?method=file|string
|
||||||
|
func (handler *Handler) edgeJobCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method. Valid values are: file or string", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case "string":
|
||||||
|
return handler.createEdgeJobFromFileContent(w, r)
|
||||||
|
case "file":
|
||||||
|
return handler.createEdgeJobFromFile(w, r)
|
||||||
|
default:
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method. Valid values are: file or string", errors.New(request.ErrInvalidQueryParameter)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type edgeJobCreateFromFileContentPayload struct {
|
||||||
|
Name string
|
||||||
|
CronExpression string
|
||||||
|
Recurring bool
|
||||||
|
Endpoints []portainer.EndpointID
|
||||||
|
FileContent string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *edgeJobCreateFromFileContentPayload) Validate(r *http.Request) error {
|
||||||
|
if govalidator.IsNull(payload.Name) {
|
||||||
|
return portainer.Error("Invalid Edge job name")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`) {
|
||||||
|
return errors.New("Invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
|
||||||
|
}
|
||||||
|
|
||||||
|
if govalidator.IsNull(payload.CronExpression) {
|
||||||
|
return portainer.Error("Invalid cron expression")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Endpoints == nil || len(payload.Endpoints) == 0 {
|
||||||
|
return portainer.Error("Invalid endpoints payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
if govalidator.IsNull(payload.FileContent) {
|
||||||
|
return portainer.Error("Invalid script file content")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) createEdgeJobFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
var payload edgeJobCreateFromFileContentPayload
|
||||||
|
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJob := handler.createEdgeJobObjectFromFileContentPayload(&payload)
|
||||||
|
|
||||||
|
err = handler.addAndPersistEdgeJob(edgeJob, []byte(payload.FileContent))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule Edge job", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, edgeJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
type edgeJobCreateFromFilePayload struct {
|
||||||
|
Name string
|
||||||
|
CronExpression string
|
||||||
|
Recurring bool
|
||||||
|
Endpoints []portainer.EndpointID
|
||||||
|
File []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *edgeJobCreateFromFilePayload) Validate(r *http.Request) error {
|
||||||
|
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("Invalid Edge job name")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
|
||||||
|
return errors.New("Invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
|
||||||
|
}
|
||||||
|
payload.Name = name
|
||||||
|
|
||||||
|
cronExpression, err := request.RetrieveMultiPartFormValue(r, "CronExpression", false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("Invalid cron expression")
|
||||||
|
}
|
||||||
|
payload.CronExpression = cronExpression
|
||||||
|
|
||||||
|
var endpoints []portainer.EndpointID
|
||||||
|
err = request.RetrieveMultiPartFormJSONValue(r, "Endpoints", &endpoints, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("Invalid endpoints")
|
||||||
|
}
|
||||||
|
payload.Endpoints = endpoints
|
||||||
|
|
||||||
|
file, _, err := request.RetrieveMultiPartFormFile(r, "file")
|
||||||
|
if err != nil {
|
||||||
|
return portainer.Error("Invalid script file. Ensure that the file is uploaded correctly")
|
||||||
|
}
|
||||||
|
payload.File = file
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) createEdgeJobFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
payload := &edgeJobCreateFromFilePayload{}
|
||||||
|
err := payload.Validate(r)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJob := handler.createEdgeJobObjectFromFilePayload(payload)
|
||||||
|
|
||||||
|
err = handler.addAndPersistEdgeJob(edgeJob, payload.File)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule Edge job", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, edgeJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) createEdgeJobObjectFromFilePayload(payload *edgeJobCreateFromFilePayload) *portainer.EdgeJob {
|
||||||
|
edgeJobIdentifier := portainer.EdgeJobID(handler.DataStore.EdgeJob().GetNextIdentifier())
|
||||||
|
|
||||||
|
endpoints := convertEndpointsToMetaObject(payload.Endpoints)
|
||||||
|
|
||||||
|
edgeJob := &portainer.EdgeJob{
|
||||||
|
ID: edgeJobIdentifier,
|
||||||
|
Name: payload.Name,
|
||||||
|
CronExpression: payload.CronExpression,
|
||||||
|
Recurring: payload.Recurring,
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
Endpoints: endpoints,
|
||||||
|
Version: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return edgeJob
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) createEdgeJobObjectFromFileContentPayload(payload *edgeJobCreateFromFileContentPayload) *portainer.EdgeJob {
|
||||||
|
edgeJobIdentifier := portainer.EdgeJobID(handler.DataStore.EdgeJob().GetNextIdentifier())
|
||||||
|
|
||||||
|
endpoints := convertEndpointsToMetaObject(payload.Endpoints)
|
||||||
|
|
||||||
|
edgeJob := &portainer.EdgeJob{
|
||||||
|
ID: edgeJobIdentifier,
|
||||||
|
Name: payload.Name,
|
||||||
|
CronExpression: payload.CronExpression,
|
||||||
|
Recurring: payload.Recurring,
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
Endpoints: endpoints,
|
||||||
|
Version: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return edgeJob
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) addAndPersistEdgeJob(edgeJob *portainer.EdgeJob, file []byte) error {
|
||||||
|
edgeCronExpression := strings.Split(edgeJob.CronExpression, " ")
|
||||||
|
if len(edgeCronExpression) == 6 {
|
||||||
|
edgeCronExpression = edgeCronExpression[1:]
|
||||||
|
}
|
||||||
|
edgeJob.CronExpression = strings.Join(edgeCronExpression, " ")
|
||||||
|
|
||||||
|
for ID := range edgeJob.Endpoints {
|
||||||
|
endpoint, err := handler.DataStore.Endpoint().Endpoint(ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if endpoint.Type != portainer.EdgeAgentEnvironment {
|
||||||
|
delete(edgeJob.Endpoints, ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(edgeJob.Endpoints) == 0 {
|
||||||
|
return errors.New("Endpoints are mandatory for an Edge job")
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptPath, err := handler.FileService.StoreEdgeJobFileFromBytes(strconv.Itoa(int(edgeJob.ID)), file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
edgeJob.ScriptPath = scriptPath
|
||||||
|
|
||||||
|
for endpointID := range edgeJob.Endpoints {
|
||||||
|
handler.ReverseTunnelService.AddEdgeJob(endpointID, edgeJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.DataStore.EdgeJob().CreateEdgeJob(edgeJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertEndpointsToMetaObject(endpoints []portainer.EndpointID) map[portainer.EndpointID]portainer.EdgeJobEndpointMeta {
|
||||||
|
endpointsMap := map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{}
|
||||||
|
|
||||||
|
for _, endpointID := range endpoints {
|
||||||
|
endpointsMap[endpointID] = portainer.EdgeJobEndpointMeta{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpointsMap
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
package edgejobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (handler *Handler) edgeJobDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJobFolder := handler.FileService.GetEdgeJobFolder(strconv.Itoa(edgeJobID))
|
||||||
|
err = handler.FileService.RemoveDirectory(edgeJobFolder)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the Edge job on the filesystem", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ReverseTunnelService.RemoveEdgeJob(edgeJob.ID)
|
||||||
|
|
||||||
|
err = handler.DataStore.EdgeJob().DeleteEdgeJob(edgeJob.ID)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the Edge job from the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
package edgejobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type edgeJobFileResponse struct {
|
||||||
|
FileContent string `json:"FileContent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET request on /api/edge_jobs/:id/file
|
||||||
|
func (handler *Handler) edgeJobFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJobFileContent, err := handler.FileService.GetFileContent(edgeJob.ScriptPath)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge job script file from disk", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, &edgeJobFileResponse{FileContent: string(edgeJobFileContent)})
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
package edgejobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
)
|
||||||
|
|
||||||
|
type edgeJobInspectResponse struct {
|
||||||
|
*portainer.EdgeJob
|
||||||
|
Endpoints []portainer.EndpointID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) edgeJobInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointIDs := []portainer.EndpointID{}
|
||||||
|
|
||||||
|
for endpointID := range edgeJob.Endpoints {
|
||||||
|
endpointIDs = append(endpointIDs, endpointID)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseObj := edgeJobInspectResponse{
|
||||||
|
EdgeJob: edgeJob,
|
||||||
|
Endpoints: endpointIDs,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, responseObj)
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
package edgejobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET request on /api/edge_jobs
|
||||||
|
func (handler *Handler) edgeJobList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
edgeJobs, err := handler.DataStore.EdgeJob().EdgeJobs()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge jobs from the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, edgeJobs)
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
package edgejobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DELETE request on /api/edge_jobs/:id/tasks/:taskID/logs
|
||||||
|
func (handler *Handler) edgeJobTasksClear(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID, err := request.RetrieveNumericRouteVariableValue(r, "taskID")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Task identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointID := portainer.EndpointID(taskID)
|
||||||
|
|
||||||
|
meta := edgeJob.Endpoints[endpointID]
|
||||||
|
meta.CollectLogs = false
|
||||||
|
meta.LogsStatus = portainer.EdgeJobLogsStatusIdle
|
||||||
|
edgeJob.Endpoints[endpointID] = meta
|
||||||
|
|
||||||
|
err = handler.FileService.ClearEdgeJobTaskLogs(strconv.Itoa(edgeJobID), strconv.Itoa(taskID))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clear log file from disk", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ReverseTunnelService.AddEdgeJob(endpointID, edgeJob)
|
||||||
|
|
||||||
|
err = handler.DataStore.EdgeJob().UpdateEdgeJob(edgeJob.ID, edgeJob)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Edge job changes in the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
package edgejobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST request on /api/edge_jobs/:id/tasks/:taskID/logs
|
||||||
|
func (handler *Handler) edgeJobTasksCollect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID, err := request.RetrieveNumericRouteVariableValue(r, "taskID")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Task identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointID := portainer.EndpointID(taskID)
|
||||||
|
|
||||||
|
meta := edgeJob.Endpoints[endpointID]
|
||||||
|
meta.CollectLogs = true
|
||||||
|
meta.LogsStatus = portainer.EdgeJobLogsStatusPending
|
||||||
|
edgeJob.Endpoints[endpointID] = meta
|
||||||
|
|
||||||
|
handler.ReverseTunnelService.AddEdgeJob(endpointID, edgeJob)
|
||||||
|
|
||||||
|
err = handler.DataStore.EdgeJob().UpdateEdgeJob(edgeJob.ID, edgeJob)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Edge job changes in the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
package edgejobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fileResponse struct {
|
||||||
|
FileContent string `json:"FileContent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET request on /api/edge_jobs/:id/tasks/:taskID/logs
|
||||||
|
func (handler *Handler) edgeJobTaskLogsInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID, err := request.RetrieveNumericRouteVariableValue(r, "taskID")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Task identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
logFileContent, err := handler.FileService.GetEdgeJobTaskLogFileContent(strconv.Itoa(edgeJobID), strconv.Itoa(taskID))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve log file from disk", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, &fileResponse{FileContent: string(logFileContent)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmt.Sprintf("/tmp/edge_jobs/%s/logs_%s", edgeJobID, taskID)
|
|
@ -0,0 +1,56 @@
|
||||||
|
package edgejobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type taskContainer struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
EndpointID portainer.EndpointID `json:"EndpointId"`
|
||||||
|
LogsStatus portainer.EdgeJobLogsStatus `json:"LogsStatus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET request on /api/edge_jobs/:id/tasks
|
||||||
|
func (handler *Handler) edgeJobTasksList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !settings.EnableEdgeComputeFeatures {
|
||||||
|
return &httperror.HandlerError{http.StatusServiceUnavailable, "Edge compute features are disabled", portainer.ErrHostManagementFeaturesDisabled}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks := make([]taskContainer, 0)
|
||||||
|
|
||||||
|
for endpointID, meta := range edgeJob.Endpoints {
|
||||||
|
|
||||||
|
cronTask := taskContainer{
|
||||||
|
ID: fmt.Sprintf("edgejob_task_%d_%d", edgeJob.ID, endpointID),
|
||||||
|
EndpointID: endpointID,
|
||||||
|
LogsStatus: meta.LogsStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks = append(tasks, cronTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, tasks)
|
||||||
|
}
|
|
@ -0,0 +1,128 @@
|
||||||
|
package edgejobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/asaskevich/govalidator"
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type edgeJobUpdatePayload struct {
|
||||||
|
Name *string
|
||||||
|
CronExpression *string
|
||||||
|
Recurring *bool
|
||||||
|
Endpoints []portainer.EndpointID
|
||||||
|
FileContent *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *edgeJobUpdatePayload) Validate(r *http.Request) error {
|
||||||
|
if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
|
||||||
|
return errors.New("Invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) edgeJobUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
settings, err := handler.DataStore.Settings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !settings.EnableEdgeComputeFeatures {
|
||||||
|
return &httperror.HandlerError{http.StatusServiceUnavailable, "Edge compute features are disabled", portainer.ErrHostManagementFeaturesDisabled}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Edge job identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload edgeJobUpdatePayload
|
||||||
|
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an Edge job with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.updateEdgeSchedule(edgeJob, &payload)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update Edge job", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.DataStore.EdgeJob().UpdateEdgeJob(edgeJob.ID, edgeJob)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Edge job changes inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, edgeJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) updateEdgeSchedule(edgeJob *portainer.EdgeJob, payload *edgeJobUpdatePayload) error {
|
||||||
|
if payload.Name != nil {
|
||||||
|
edgeJob.Name = *payload.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Endpoints != nil {
|
||||||
|
endpointsMap := map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{}
|
||||||
|
|
||||||
|
for _, endpointID := range payload.Endpoints {
|
||||||
|
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if endpoint.Type != portainer.EdgeAgentEnvironment {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta, ok := edgeJob.Endpoints[endpointID]; ok {
|
||||||
|
endpointsMap[endpointID] = meta
|
||||||
|
} else {
|
||||||
|
endpointsMap[endpointID] = portainer.EdgeJobEndpointMeta{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJob.Endpoints = endpointsMap
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVersion := false
|
||||||
|
if payload.CronExpression != nil {
|
||||||
|
edgeJob.CronExpression = *payload.CronExpression
|
||||||
|
updateVersion = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.FileContent != nil {
|
||||||
|
_, err := handler.FileService.StoreEdgeJobFileFromBytes(strconv.Itoa(int(edgeJob.ID)), []byte(*payload.FileContent))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVersion = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Recurring != nil {
|
||||||
|
edgeJob.Recurring = *payload.Recurring
|
||||||
|
updateVersion = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateVersion {
|
||||||
|
edgeJob.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
for endpointID := range edgeJob.Endpoints {
|
||||||
|
handler.ReverseTunnelService.AddEdgeJob(endpointID, edgeJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
package edgejobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler is the HTTP handler used to handle Edge job operations.
|
||||||
|
type Handler struct {
|
||||||
|
*mux.Router
|
||||||
|
DataStore portainer.DataStore
|
||||||
|
FileService portainer.FileService
|
||||||
|
ReverseTunnelService portainer.ReverseTunnelService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a handler to manage Edge job operations.
|
||||||
|
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
|
h := &Handler{
|
||||||
|
Router: mux.NewRouter(),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Handle("/edge_jobs",
|
||||||
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/edge_jobs",
|
||||||
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/edge_jobs/{id}",
|
||||||
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobInspect)))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/edge_jobs/{id}",
|
||||||
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobUpdate)))).Methods(http.MethodPut)
|
||||||
|
h.Handle("/edge_jobs/{id}",
|
||||||
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobDelete)))).Methods(http.MethodDelete)
|
||||||
|
h.Handle("/edge_jobs/{id}/file",
|
||||||
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobFile)))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/edge_jobs/{id}/tasks",
|
||||||
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobTasksList)))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/edge_jobs/{id}/tasks/{taskID}/logs",
|
||||||
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobTaskLogsInspect)))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/edge_jobs/{id}/tasks/{taskID}/logs",
|
||||||
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobTasksCollect)))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/edge_jobs/{id}/tasks/{taskID}/logs",
|
||||||
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobTasksClear)))).Methods(http.MethodDelete)
|
||||||
|
return h
|
||||||
|
}
|
|
@ -25,17 +25,17 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
requestBouncer: bouncer,
|
requestBouncer: bouncer,
|
||||||
}
|
}
|
||||||
h.Handle("/edge_stacks",
|
h.Handle("/edge_stacks",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackCreate))).Methods(http.MethodPost)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
|
||||||
h.Handle("/edge_stacks",
|
h.Handle("/edge_stacks",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackList))).Methods(http.MethodGet)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
|
||||||
h.Handle("/edge_stacks/{id}",
|
h.Handle("/edge_stacks/{id}",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackInspect))).Methods(http.MethodGet)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackInspect)))).Methods(http.MethodGet)
|
||||||
h.Handle("/edge_stacks/{id}",
|
h.Handle("/edge_stacks/{id}",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackUpdate))).Methods(http.MethodPut)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackUpdate)))).Methods(http.MethodPut)
|
||||||
h.Handle("/edge_stacks/{id}",
|
h.Handle("/edge_stacks/{id}",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackDelete))).Methods(http.MethodDelete)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackDelete)))).Methods(http.MethodDelete)
|
||||||
h.Handle("/edge_stacks/{id}/file",
|
h.Handle("/edge_stacks/{id}/file",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.edgeStackFile))).Methods(http.MethodGet)
|
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackFile)))).Methods(http.MethodGet)
|
||||||
h.Handle("/edge_stacks/{id}/status",
|
h.Handle("/edge_stacks/{id}/status",
|
||||||
bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusUpdate))).Methods(http.MethodPut)
|
bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusUpdate))).Methods(http.MethodPut)
|
||||||
return h
|
return h
|
||||||
|
|
|
@ -0,0 +1,77 @@
|
||||||
|
package endpointedge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type logsPayload struct {
|
||||||
|
FileContent string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *logsPayload) Validate(r *http.Request) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST request on api/endpoints/:id/edge/jobs/:jobID/logs
|
||||||
|
func (handler *Handler) endpointEdgeJobsLogs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "jobID")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid edge job identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload logsPayload
|
||||||
|
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeJob, err := handler.DataStore.EdgeJob().EdgeJob(portainer.EdgeJobID(edgeJobID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge job with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge job with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.FileService.StoreEdgeJobTaskLogFileFromBytes(strconv.Itoa(edgeJobID), strconv.Itoa(endpointID), []byte(payload.FileContent))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to save task log to the filesystem", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := edgeJob.Endpoints[endpoint.ID]
|
||||||
|
meta.CollectLogs = false
|
||||||
|
meta.LogsStatus = portainer.EdgeJobLogsStatusCollected
|
||||||
|
edgeJob.Endpoints[endpoint.ID] = meta
|
||||||
|
|
||||||
|
err = handler.DataStore.EdgeJob().UpdateEdgeJob(edgeJob.ID, edgeJob)
|
||||||
|
|
||||||
|
handler.ReverseTunnelService.AddEdgeJob(endpoint.ID, edgeJob)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist edge job changes to the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, nil)
|
||||||
|
}
|
|
@ -13,9 +13,10 @@ import (
|
||||||
// Handler is the HTTP handler used to handle edge endpoint operations.
|
// Handler is the HTTP handler used to handle edge endpoint operations.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
requestBouncer *security.RequestBouncer
|
requestBouncer *security.RequestBouncer
|
||||||
DataStore portainer.DataStore
|
DataStore portainer.DataStore
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
|
ReverseTunnelService portainer.ReverseTunnelService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage endpoint operations.
|
// NewHandler creates a handler to manage endpoint operations.
|
||||||
|
@ -27,6 +28,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
|
|
||||||
h.Handle("/{id}/edge/stacks/{stackId}",
|
h.Handle("/{id}/edge/stacks/{stackId}",
|
||||||
bouncer.PublicAccess(httperror.LoggerHandler(h.endpointEdgeStackInspect))).Methods(http.MethodGet)
|
bouncer.PublicAccess(httperror.LoggerHandler(h.endpointEdgeStackInspect))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/{id}/edge/jobs/{jobID}/logs",
|
||||||
|
bouncer.PublicAccess(httperror.LoggerHandler(h.endpointEdgeJobsLogs))).Methods(http.MethodPost)
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,111 +0,0 @@
|
||||||
package endpoints
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/asaskevich/govalidator"
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
|
||||||
"github.com/portainer/libhttp/request"
|
|
||||||
"github.com/portainer/libhttp/response"
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
type endpointJobFromFilePayload struct {
|
|
||||||
Image string
|
|
||||||
File []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
type endpointJobFromFileContentPayload struct {
|
|
||||||
Image string
|
|
||||||
FileContent string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (payload *endpointJobFromFilePayload) Validate(r *http.Request) error {
|
|
||||||
file, _, err := request.RetrieveMultiPartFormFile(r, "File")
|
|
||||||
if err != nil {
|
|
||||||
return portainer.Error("Invalid Script file. Ensure that the file is uploaded correctly")
|
|
||||||
}
|
|
||||||
payload.File = file
|
|
||||||
|
|
||||||
image, err := request.RetrieveMultiPartFormValue(r, "Image", false)
|
|
||||||
if err != nil {
|
|
||||||
return portainer.Error("Invalid image name")
|
|
||||||
}
|
|
||||||
payload.Image = image
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (payload *endpointJobFromFileContentPayload) Validate(r *http.Request) error {
|
|
||||||
if govalidator.IsNull(payload.FileContent) {
|
|
||||||
return portainer.Error("Invalid script file content")
|
|
||||||
}
|
|
||||||
|
|
||||||
if govalidator.IsNull(payload.Image) {
|
|
||||||
return portainer.Error("Invalid image name")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST request on /api/endpoints/:id/job?method&nodeName
|
|
||||||
func (handler *Handler) endpointJob(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeName, _ := request.RetrieveQueryParameter(r, "nodeName", true)
|
|
||||||
|
|
||||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
|
||||||
if err == portainer.ErrObjectNotFound {
|
|
||||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
|
||||||
} else if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch method {
|
|
||||||
case "file":
|
|
||||||
return handler.executeJobFromFile(w, r, endpoint, nodeName)
|
|
||||||
case "string":
|
|
||||||
return handler.executeJobFromFileContent(w, r, endpoint, nodeName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string or file", errors.New(request.ErrInvalidQueryParameter)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError {
|
|
||||||
payload := &endpointJobFromFilePayload{}
|
|
||||||
err := payload.Validate(r)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, payload.File, nil)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Empty(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) executeJobFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError {
|
|
||||||
var payload endpointJobFromFileContentPayload
|
|
||||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, []byte(payload.FileContent), nil)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Empty(w)
|
|
||||||
}
|
|
|
@ -1,6 +1,7 @@
|
||||||
package endpoints
|
package endpoints
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
@ -14,13 +15,21 @@ type stackStatusResponse struct {
|
||||||
Version int
|
Version int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type edgeJobResponse struct {
|
||||||
|
ID portainer.EdgeJobID `json:"Id"`
|
||||||
|
CollectLogs bool `json:"CollectLogs"`
|
||||||
|
CronExpression string `json:"CronExpression"`
|
||||||
|
Script string `json:"Script"`
|
||||||
|
Version int `json:"Version"`
|
||||||
|
}
|
||||||
|
|
||||||
type endpointStatusInspectResponse struct {
|
type endpointStatusInspectResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Schedules []portainer.EdgeSchedule `json:"schedules"`
|
Schedules []edgeJobResponse `json:"schedules"`
|
||||||
CheckinInterval int `json:"checkin"`
|
CheckinInterval int `json:"checkin"`
|
||||||
Credentials string `json:"credentials"`
|
Credentials string `json:"credentials"`
|
||||||
Stacks []stackStatusResponse `json:"stacks"`
|
Stacks []stackStatusResponse `json:"stacks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET request on /api/endpoints/:id/status
|
// GET request on /api/endpoints/:id/status
|
||||||
|
@ -65,10 +74,30 @@ func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Req
|
||||||
checkinInterval = endpoint.EdgeCheckinInterval
|
checkinInterval = endpoint.EdgeCheckinInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
schedules := []edgeJobResponse{}
|
||||||
|
for _, job := range tunnel.Jobs {
|
||||||
|
schedule := edgeJobResponse{
|
||||||
|
ID: job.ID,
|
||||||
|
CronExpression: job.CronExpression,
|
||||||
|
CollectLogs: job.Endpoints[endpoint.ID].CollectLogs,
|
||||||
|
Version: job.Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := handler.FileService.GetFileContent(job.ScriptPath)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Edge job script file", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule.Script = base64.RawStdEncoding.EncodeToString(file)
|
||||||
|
|
||||||
|
schedules = append(schedules, schedule)
|
||||||
|
}
|
||||||
|
|
||||||
statusResponse := endpointStatusInspectResponse{
|
statusResponse := endpointStatusInspectResponse{
|
||||||
Status: tunnel.Status,
|
Status: tunnel.Status,
|
||||||
Port: tunnel.Port,
|
Port: tunnel.Port,
|
||||||
Schedules: tunnel.Schedules,
|
Schedules: schedules,
|
||||||
CheckinInterval: checkinInterval,
|
CheckinInterval: checkinInterval,
|
||||||
Credentials: tunnel.Credentials,
|
Credentials: tunnel.Credentials,
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,6 @@ type Handler struct {
|
||||||
DataStore portainer.DataStore
|
DataStore portainer.DataStore
|
||||||
AuthorizationService *authorization.Service
|
AuthorizationService *authorization.Service
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
JobService portainer.JobService
|
|
||||||
ProxyManager *proxy.Manager
|
ProxyManager *proxy.Manager
|
||||||
ReverseTunnelService portainer.ReverseTunnelService
|
ReverseTunnelService portainer.ReverseTunnelService
|
||||||
Snapshotter portainer.Snapshotter
|
Snapshotter portainer.Snapshotter
|
||||||
|
@ -55,8 +54,6 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost)
|
||||||
h.Handle("/endpoints/{id}/extensions/{extensionType}",
|
h.Handle("/endpoints/{id}/extensions/{extensionType}",
|
||||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete)
|
||||||
h.Handle("/endpoints/{id}/job",
|
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost)
|
|
||||||
h.Handle("/endpoints/{id}/snapshot",
|
h.Handle("/endpoints/{id}/snapshot",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
|
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
|
||||||
h.Handle("/endpoints/{id}/status",
|
h.Handle("/endpoints/{id}/status",
|
||||||
|
|
|
@ -4,18 +4,13 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api/http/handler/auth"
|
||||||
|
"github.com/portainer/portainer/api/http/handler/dockerhub"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
||||||
|
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||||
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
||||||
"github.com/portainer/portainer/api/http/handler/support"
|
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/http/handler/schedules"
|
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/http/handler/roles"
|
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/http/handler/auth"
|
|
||||||
"github.com/portainer/portainer/api/http/handler/dockerhub"
|
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpoints"
|
"github.com/portainer/portainer/api/http/handler/endpoints"
|
||||||
|
@ -24,9 +19,11 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/handler/motd"
|
"github.com/portainer/portainer/api/http/handler/motd"
|
||||||
"github.com/portainer/portainer/api/http/handler/registries"
|
"github.com/portainer/portainer/api/http/handler/registries"
|
||||||
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
|
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
|
||||||
|
"github.com/portainer/portainer/api/http/handler/roles"
|
||||||
"github.com/portainer/portainer/api/http/handler/settings"
|
"github.com/portainer/portainer/api/http/handler/settings"
|
||||||
"github.com/portainer/portainer/api/http/handler/stacks"
|
"github.com/portainer/portainer/api/http/handler/stacks"
|
||||||
"github.com/portainer/portainer/api/http/handler/status"
|
"github.com/portainer/portainer/api/http/handler/status"
|
||||||
|
"github.com/portainer/portainer/api/http/handler/support"
|
||||||
"github.com/portainer/portainer/api/http/handler/tags"
|
"github.com/portainer/portainer/api/http/handler/tags"
|
||||||
"github.com/portainer/portainer/api/http/handler/teammemberships"
|
"github.com/portainer/portainer/api/http/handler/teammemberships"
|
||||||
"github.com/portainer/portainer/api/http/handler/teams"
|
"github.com/portainer/portainer/api/http/handler/teams"
|
||||||
|
@ -42,6 +39,7 @@ type Handler struct {
|
||||||
AuthHandler *auth.Handler
|
AuthHandler *auth.Handler
|
||||||
DockerHubHandler *dockerhub.Handler
|
DockerHubHandler *dockerhub.Handler
|
||||||
EdgeGroupsHandler *edgegroups.Handler
|
EdgeGroupsHandler *edgegroups.Handler
|
||||||
|
EdgeJobsHandler *edgejobs.Handler
|
||||||
EdgeStacksHandler *edgestacks.Handler
|
EdgeStacksHandler *edgestacks.Handler
|
||||||
EdgeTemplatesHandler *edgetemplates.Handler
|
EdgeTemplatesHandler *edgetemplates.Handler
|
||||||
EndpointEdgeHandler *endpointedge.Handler
|
EndpointEdgeHandler *endpointedge.Handler
|
||||||
|
@ -54,7 +52,6 @@ type Handler struct {
|
||||||
RegistryHandler *registries.Handler
|
RegistryHandler *registries.Handler
|
||||||
ResourceControlHandler *resourcecontrols.Handler
|
ResourceControlHandler *resourcecontrols.Handler
|
||||||
RoleHandler *roles.Handler
|
RoleHandler *roles.Handler
|
||||||
SchedulesHanlder *schedules.Handler
|
|
||||||
SettingsHandler *settings.Handler
|
SettingsHandler *settings.Handler
|
||||||
StackHandler *stacks.Handler
|
StackHandler *stacks.Handler
|
||||||
StatusHandler *status.Handler
|
StatusHandler *status.Handler
|
||||||
|
@ -76,10 +73,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
|
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
|
||||||
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.DockerHubHandler).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"):
|
case strings.HasPrefix(r.URL.Path, "/api/edge_groups"):
|
||||||
http.StripPrefix("/api", h.EdgeGroupsHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.EdgeGroupsHandler).ServeHTTP(w, r)
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/edge_jobs"):
|
||||||
|
http.StripPrefix("/api", h.EdgeJobsHandler).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_templates"):
|
case strings.HasPrefix(r.URL.Path, "/api/edge_templates"):
|
||||||
http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
|
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
|
||||||
|
@ -107,8 +106,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
http.StripPrefix("/api", h.ResourceControlHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.ResourceControlHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/roles"):
|
case strings.HasPrefix(r.URL.Path, "/api/roles"):
|
||||||
http.StripPrefix("/api", h.RoleHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.RoleHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/schedules"):
|
|
||||||
http.StripPrefix("/api", h.SchedulesHanlder).ServeHTTP(w, r)
|
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/settings"):
|
case strings.HasPrefix(r.URL.Path, "/api/settings"):
|
||||||
http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r)
|
http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r)
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/stacks"):
|
case strings.HasPrefix(r.URL.Path, "/api/stacks"):
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
package schedules
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/http/security"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler is the HTTP handler used to handle schedule operations.
|
|
||||||
type Handler struct {
|
|
||||||
*mux.Router
|
|
||||||
DataStore portainer.DataStore
|
|
||||||
FileService portainer.FileService
|
|
||||||
JobService portainer.JobService
|
|
||||||
JobScheduler portainer.JobScheduler
|
|
||||||
ReverseTunnelService portainer.ReverseTunnelService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHandler creates a handler to manage schedule operations.
|
|
||||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
|
||||||
h := &Handler{
|
|
||||||
Router: mux.NewRouter(),
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Handle("/schedules",
|
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleList))).Methods(http.MethodGet)
|
|
||||||
h.Handle("/schedules",
|
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleCreate))).Methods(http.MethodPost)
|
|
||||||
h.Handle("/schedules/{id}",
|
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleInspect))).Methods(http.MethodGet)
|
|
||||||
h.Handle("/schedules/{id}",
|
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut)
|
|
||||||
h.Handle("/schedules/{id}",
|
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete)
|
|
||||||
h.Handle("/schedules/{id}/file",
|
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet)
|
|
||||||
h.Handle("/schedules/{id}/tasks",
|
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.scheduleTasks))).Methods(http.MethodGet)
|
|
||||||
return h
|
|
||||||
}
|
|
|
@ -1,280 +0,0 @@
|
||||||
package schedules
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/asaskevich/govalidator"
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
|
||||||
"github.com/portainer/libhttp/request"
|
|
||||||
"github.com/portainer/libhttp/response"
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/cron"
|
|
||||||
)
|
|
||||||
|
|
||||||
type scheduleCreateFromFilePayload struct {
|
|
||||||
Name string
|
|
||||||
Image string
|
|
||||||
CronExpression string
|
|
||||||
Recurring bool
|
|
||||||
Endpoints []portainer.EndpointID
|
|
||||||
File []byte
|
|
||||||
RetryCount int
|
|
||||||
RetryInterval int
|
|
||||||
}
|
|
||||||
|
|
||||||
type scheduleCreateFromFileContentPayload struct {
|
|
||||||
Name string
|
|
||||||
CronExpression string
|
|
||||||
Recurring bool
|
|
||||||
Image string
|
|
||||||
Endpoints []portainer.EndpointID
|
|
||||||
FileContent string
|
|
||||||
RetryCount int
|
|
||||||
RetryInterval int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (payload *scheduleCreateFromFilePayload) Validate(r *http.Request) error {
|
|
||||||
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("Invalid schedule name")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
|
|
||||||
return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]")
|
|
||||||
}
|
|
||||||
payload.Name = name
|
|
||||||
|
|
||||||
image, err := request.RetrieveMultiPartFormValue(r, "Image", false)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("Invalid schedule image")
|
|
||||||
}
|
|
||||||
payload.Image = image
|
|
||||||
|
|
||||||
cronExpression, err := request.RetrieveMultiPartFormValue(r, "CronExpression", false)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("Invalid cron expression")
|
|
||||||
}
|
|
||||||
payload.CronExpression = cronExpression
|
|
||||||
|
|
||||||
var endpoints []portainer.EndpointID
|
|
||||||
err = request.RetrieveMultiPartFormJSONValue(r, "Endpoints", &endpoints, false)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New("Invalid endpoints")
|
|
||||||
}
|
|
||||||
payload.Endpoints = endpoints
|
|
||||||
|
|
||||||
file, _, err := request.RetrieveMultiPartFormFile(r, "file")
|
|
||||||
if err != nil {
|
|
||||||
return portainer.Error("Invalid script file. Ensure that the file is uploaded correctly")
|
|
||||||
}
|
|
||||||
payload.File = file
|
|
||||||
|
|
||||||
retryCount, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryCount", true)
|
|
||||||
payload.RetryCount = retryCount
|
|
||||||
|
|
||||||
retryInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryInterval", true)
|
|
||||||
payload.RetryInterval = retryInterval
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (payload *scheduleCreateFromFileContentPayload) Validate(r *http.Request) error {
|
|
||||||
if govalidator.IsNull(payload.Name) {
|
|
||||||
return portainer.Error("Invalid schedule name")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
|
|
||||||
return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]")
|
|
||||||
}
|
|
||||||
|
|
||||||
if govalidator.IsNull(payload.Image) {
|
|
||||||
return portainer.Error("Invalid schedule image")
|
|
||||||
}
|
|
||||||
|
|
||||||
if govalidator.IsNull(payload.CronExpression) {
|
|
||||||
return portainer.Error("Invalid cron expression")
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Endpoints == nil || len(payload.Endpoints) == 0 {
|
|
||||||
return portainer.Error("Invalid endpoints payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
if govalidator.IsNull(payload.FileContent) {
|
|
||||||
return portainer.Error("Invalid script file content")
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.RetryCount != 0 && payload.RetryInterval == 0 {
|
|
||||||
return portainer.Error("RetryInterval must be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/schedules?method=file|string
|
|
||||||
func (handler *Handler) scheduleCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
settings, err := handler.DataStore.Settings().Settings()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
|
|
||||||
}
|
|
||||||
if !settings.EnableHostManagementFeatures {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
|
|
||||||
}
|
|
||||||
|
|
||||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method. Valid values are: file or string", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch method {
|
|
||||||
case "string":
|
|
||||||
return handler.createScheduleFromFileContent(w, r)
|
|
||||||
case "file":
|
|
||||||
return handler.createScheduleFromFile(w, r)
|
|
||||||
default:
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method. Valid values are: file or string", errors.New(request.ErrInvalidQueryParameter)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) createScheduleFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
var payload scheduleCreateFromFileContentPayload
|
|
||||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule := handler.createScheduleObjectFromFileContentPayload(&payload)
|
|
||||||
|
|
||||||
err = handler.addAndPersistSchedule(schedule, []byte(payload.FileContent))
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, schedule)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) createScheduleFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
payload := &scheduleCreateFromFilePayload{}
|
|
||||||
err := payload.Validate(r)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule := handler.createScheduleObjectFromFilePayload(payload)
|
|
||||||
|
|
||||||
err = handler.addAndPersistSchedule(schedule, payload.File)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, schedule)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) createScheduleObjectFromFilePayload(payload *scheduleCreateFromFilePayload) *portainer.Schedule {
|
|
||||||
scheduleIdentifier := portainer.ScheduleID(handler.DataStore.Schedule().GetNextIdentifier())
|
|
||||||
|
|
||||||
job := &portainer.ScriptExecutionJob{
|
|
||||||
Endpoints: payload.Endpoints,
|
|
||||||
Image: payload.Image,
|
|
||||||
RetryCount: payload.RetryCount,
|
|
||||||
RetryInterval: payload.RetryInterval,
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule := &portainer.Schedule{
|
|
||||||
ID: scheduleIdentifier,
|
|
||||||
Name: payload.Name,
|
|
||||||
CronExpression: payload.CronExpression,
|
|
||||||
Recurring: payload.Recurring,
|
|
||||||
JobType: portainer.ScriptExecutionJobType,
|
|
||||||
ScriptExecutionJob: job,
|
|
||||||
Created: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return schedule
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) createScheduleObjectFromFileContentPayload(payload *scheduleCreateFromFileContentPayload) *portainer.Schedule {
|
|
||||||
scheduleIdentifier := portainer.ScheduleID(handler.DataStore.Schedule().GetNextIdentifier())
|
|
||||||
|
|
||||||
job := &portainer.ScriptExecutionJob{
|
|
||||||
Endpoints: payload.Endpoints,
|
|
||||||
Image: payload.Image,
|
|
||||||
RetryCount: payload.RetryCount,
|
|
||||||
RetryInterval: payload.RetryInterval,
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule := &portainer.Schedule{
|
|
||||||
ID: scheduleIdentifier,
|
|
||||||
Name: payload.Name,
|
|
||||||
CronExpression: payload.CronExpression,
|
|
||||||
Recurring: payload.Recurring,
|
|
||||||
JobType: portainer.ScriptExecutionJobType,
|
|
||||||
ScriptExecutionJob: job,
|
|
||||||
Created: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
return schedule
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) addAndPersistSchedule(schedule *portainer.Schedule, file []byte) error {
|
|
||||||
nonEdgeEndpointIDs := make([]portainer.EndpointID, 0)
|
|
||||||
edgeEndpointIDs := make([]portainer.EndpointID, 0)
|
|
||||||
|
|
||||||
edgeCronExpression := strings.Split(schedule.CronExpression, " ")
|
|
||||||
if len(edgeCronExpression) == 6 {
|
|
||||||
edgeCronExpression = edgeCronExpression[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ID := range schedule.ScriptExecutionJob.Endpoints {
|
|
||||||
|
|
||||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if endpoint.Type != portainer.EdgeAgentEnvironment {
|
|
||||||
nonEdgeEndpointIDs = append(nonEdgeEndpointIDs, endpoint.ID)
|
|
||||||
} else {
|
|
||||||
edgeEndpointIDs = append(edgeEndpointIDs, endpoint.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(edgeEndpointIDs) > 0 {
|
|
||||||
edgeSchedule := &portainer.EdgeSchedule{
|
|
||||||
ID: schedule.ID,
|
|
||||||
CronExpression: strings.Join(edgeCronExpression, " "),
|
|
||||||
Script: base64.RawStdEncoding.EncodeToString(file),
|
|
||||||
Endpoints: edgeEndpointIDs,
|
|
||||||
Version: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, endpointID := range edgeEndpointIDs {
|
|
||||||
handler.ReverseTunnelService.AddSchedule(endpointID, edgeSchedule)
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule.EdgeSchedule = edgeSchedule
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule.ScriptExecutionJob.Endpoints = nonEdgeEndpointIDs
|
|
||||||
|
|
||||||
scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(schedule.ID)), file)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule.ScriptExecutionJob.ScriptPath = scriptPath
|
|
||||||
|
|
||||||
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.DataStore, handler.FileService)
|
|
||||||
jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext)
|
|
||||||
|
|
||||||
err = handler.JobScheduler.ScheduleJob(jobRunner)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return handler.DataStore.Schedule().CreateSchedule(schedule)
|
|
||||||
}
|
|
|
@ -1,55 +0,0 @@
|
||||||
package schedules
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
|
||||||
"github.com/portainer/libhttp/request"
|
|
||||||
"github.com/portainer/libhttp/response"
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (handler *Handler) scheduleDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
settings, err := handler.DataStore.Settings().Settings()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
|
|
||||||
}
|
|
||||||
if !settings.EnableHostManagementFeatures {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule, err := handler.DataStore.Schedule().Schedule(portainer.ScheduleID(scheduleID))
|
|
||||||
if err == portainer.ErrObjectNotFound {
|
|
||||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
|
|
||||||
} else if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
if schedule.JobType == portainer.SnapshotJobType || schedule.JobType == portainer.EndpointSyncJobType {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Cannot remove system schedules", errors.New("Cannot remove system schedule")}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleFolder := handler.FileService.GetScheduleFolder(strconv.Itoa(scheduleID))
|
|
||||||
err = handler.FileService.RemoveDirectory(scheduleFolder)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
handler.ReverseTunnelService.RemoveSchedule(schedule.ID)
|
|
||||||
|
|
||||||
handler.JobScheduler.UnscheduleJob(schedule.ID)
|
|
||||||
|
|
||||||
err = handler.DataStore.Schedule().DeleteSchedule(portainer.ScheduleID(scheduleID))
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the schedule from the database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Empty(w)
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
package schedules
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
|
||||||
"github.com/portainer/libhttp/request"
|
|
||||||
"github.com/portainer/libhttp/response"
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
type scheduleFileResponse struct {
|
|
||||||
ScheduleFileContent string `json:"ScheduleFileContent"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET request on /api/schedules/:id/file
|
|
||||||
func (handler *Handler) scheduleFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
settings, err := handler.DataStore.Settings().Settings()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
|
|
||||||
}
|
|
||||||
if !settings.EnableHostManagementFeatures {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule, err := handler.DataStore.Schedule().Schedule(portainer.ScheduleID(scheduleID))
|
|
||||||
if err == portainer.ErrObjectNotFound {
|
|
||||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
|
|
||||||
} else if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
if schedule.JobType != portainer.ScriptExecutionJobType {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Unable to retrieve script file", errors.New("This type of schedule do not have any associated script file")}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleFileContent, err := handler.FileService.GetFileContent(schedule.ScriptExecutionJob.ScriptPath)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve schedule script file from disk", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, &scheduleFileResponse{ScheduleFileContent: string(scheduleFileContent)})
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
package schedules
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/portainer/libhttp/response"
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
|
||||||
"github.com/portainer/libhttp/request"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (handler *Handler) scheduleInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
settings, err := handler.DataStore.Settings().Settings()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
|
|
||||||
}
|
|
||||||
if !settings.EnableHostManagementFeatures {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule, err := handler.DataStore.Schedule().Schedule(portainer.ScheduleID(scheduleID))
|
|
||||||
if err == portainer.ErrObjectNotFound {
|
|
||||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
|
|
||||||
} else if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, schedule)
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
package schedules
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
|
||||||
"github.com/portainer/libhttp/response"
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GET request on /api/schedules
|
|
||||||
func (handler *Handler) scheduleList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
settings, err := handler.DataStore.Settings().Settings()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
|
|
||||||
}
|
|
||||||
if !settings.EnableHostManagementFeatures {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedules, err := handler.DataStore.Schedule().Schedules()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve schedules from the database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, schedules)
|
|
||||||
}
|
|
|
@ -1,114 +0,0 @@
|
||||||
package schedules
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
|
||||||
"github.com/portainer/libhttp/request"
|
|
||||||
"github.com/portainer/libhttp/response"
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
type taskContainer struct {
|
|
||||||
ID string `json:"Id"`
|
|
||||||
EndpointID portainer.EndpointID `json:"EndpointId"`
|
|
||||||
Status string `json:"Status"`
|
|
||||||
Created float64 `json:"Created"`
|
|
||||||
Labels map[string]string `json:"Labels"`
|
|
||||||
Edge bool `json:"Edge"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET request on /api/schedules/:id/tasks
|
|
||||||
func (handler *Handler) scheduleTasks(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
settings, err := handler.DataStore.Settings().Settings()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
|
|
||||||
}
|
|
||||||
if !settings.EnableHostManagementFeatures {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule, err := handler.DataStore.Schedule().Schedule(portainer.ScheduleID(scheduleID))
|
|
||||||
if err == portainer.ErrObjectNotFound {
|
|
||||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
|
|
||||||
} else if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
if schedule.JobType != portainer.ScriptExecutionJobType {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Unable to retrieve schedule tasks", errors.New("This type of schedule do not have any associated tasks")}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks := make([]taskContainer, 0)
|
|
||||||
|
|
||||||
for _, endpointID := range schedule.ScriptExecutionJob.Endpoints {
|
|
||||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID)
|
|
||||||
if err == portainer.ErrObjectNotFound {
|
|
||||||
continue
|
|
||||||
} else if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointTasks, err := extractTasksFromContainerSnasphot(endpoint, schedule.ID)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find extract schedule tasks from endpoint snapshot", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks = append(tasks, endpointTasks...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if schedule.EdgeSchedule != nil {
|
|
||||||
for _, endpointID := range schedule.EdgeSchedule.Endpoints {
|
|
||||||
|
|
||||||
cronTask := taskContainer{
|
|
||||||
ID: fmt.Sprintf("schedule_%d", schedule.EdgeSchedule.ID),
|
|
||||||
EndpointID: endpointID,
|
|
||||||
Edge: true,
|
|
||||||
Status: "",
|
|
||||||
Created: 0,
|
|
||||||
Labels: map[string]string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks = append(tasks, cronTask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractTasksFromContainerSnasphot(endpoint *portainer.Endpoint, scheduleID portainer.ScheduleID) ([]taskContainer, error) {
|
|
||||||
endpointTasks := make([]taskContainer, 0)
|
|
||||||
if len(endpoint.Snapshots) == 0 {
|
|
||||||
return endpointTasks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := json.Marshal(endpoint.Snapshots[0].SnapshotRaw.Containers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var containers []taskContainer
|
|
||||||
err = json.Unmarshal(b, &containers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, container := range containers {
|
|
||||||
if container.Labels["io.portainer.schedule.id"] == strconv.Itoa(int(scheduleID)) {
|
|
||||||
container.EndpointID = endpoint.ID
|
|
||||||
container.Edge = false
|
|
||||||
endpointTasks = append(endpointTasks, container)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return endpointTasks, nil
|
|
||||||
}
|
|
|
@ -1,175 +0,0 @@
|
||||||
package schedules
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/asaskevich/govalidator"
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
|
||||||
"github.com/portainer/libhttp/request"
|
|
||||||
"github.com/portainer/libhttp/response"
|
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
"github.com/portainer/portainer/api/cron"
|
|
||||||
)
|
|
||||||
|
|
||||||
type scheduleUpdatePayload struct {
|
|
||||||
Name *string
|
|
||||||
Image *string
|
|
||||||
CronExpression *string
|
|
||||||
Recurring *bool
|
|
||||||
Endpoints []portainer.EndpointID
|
|
||||||
FileContent *string
|
|
||||||
RetryCount *int
|
|
||||||
RetryInterval *int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (payload *scheduleUpdatePayload) Validate(r *http.Request) error {
|
|
||||||
if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
|
|
||||||
return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
|
||||||
settings, err := handler.DataStore.Settings().Settings()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err}
|
|
||||||
}
|
|
||||||
if !settings.EnableHostManagementFeatures {
|
|
||||||
return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled}
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload scheduleUpdatePayload
|
|
||||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule, err := handler.DataStore.Schedule().Schedule(portainer.ScheduleID(scheduleID))
|
|
||||||
if err == portainer.ErrObjectNotFound {
|
|
||||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
|
|
||||||
} else if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateJobSchedule := false
|
|
||||||
if schedule.EdgeSchedule != nil {
|
|
||||||
err := handler.updateEdgeSchedule(schedule, &payload)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update Edge schedule", err}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updateJobSchedule = updateSchedule(schedule, &payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.FileContent != nil {
|
|
||||||
_, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(scheduleID), []byte(*payload.FileContent))
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist script file changes on the filesystem", err}
|
|
||||||
}
|
|
||||||
updateJobSchedule = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if updateJobSchedule {
|
|
||||||
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.DataStore, handler.FileService)
|
|
||||||
jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext)
|
|
||||||
err := handler.JobScheduler.UpdateJobSchedule(jobRunner)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update job scheduler", err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = handler.DataStore.Schedule().UpdateSchedule(portainer.ScheduleID(scheduleID), schedule)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist schedule changes inside the database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, schedule)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) updateEdgeSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload) error {
|
|
||||||
if payload.Name != nil {
|
|
||||||
schedule.Name = *payload.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Endpoints != nil {
|
|
||||||
|
|
||||||
edgeEndpointIDs := make([]portainer.EndpointID, 0)
|
|
||||||
|
|
||||||
for _, ID := range payload.Endpoints {
|
|
||||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if endpoint.Type == portainer.EdgeAgentEnvironment {
|
|
||||||
edgeEndpointIDs = append(edgeEndpointIDs, endpoint.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule.EdgeSchedule.Endpoints = edgeEndpointIDs
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.CronExpression != nil {
|
|
||||||
schedule.EdgeSchedule.CronExpression = *payload.CronExpression
|
|
||||||
schedule.EdgeSchedule.Version++
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.FileContent != nil {
|
|
||||||
schedule.EdgeSchedule.Script = base64.RawStdEncoding.EncodeToString([]byte(*payload.FileContent))
|
|
||||||
schedule.EdgeSchedule.Version++
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, endpointID := range schedule.EdgeSchedule.Endpoints {
|
|
||||||
handler.ReverseTunnelService.AddSchedule(endpointID, schedule.EdgeSchedule)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload) bool {
|
|
||||||
updateJobSchedule := false
|
|
||||||
|
|
||||||
if payload.Name != nil {
|
|
||||||
schedule.Name = *payload.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Endpoints != nil {
|
|
||||||
schedule.ScriptExecutionJob.Endpoints = payload.Endpoints
|
|
||||||
updateJobSchedule = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.CronExpression != nil {
|
|
||||||
schedule.CronExpression = *payload.CronExpression
|
|
||||||
updateJobSchedule = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Recurring != nil {
|
|
||||||
schedule.Recurring = *payload.Recurring
|
|
||||||
updateJobSchedule = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Image != nil {
|
|
||||||
schedule.ScriptExecutionJob.Image = *payload.Image
|
|
||||||
updateJobSchedule = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.RetryCount != nil {
|
|
||||||
schedule.ScriptExecutionJob.RetryCount = *payload.RetryCount
|
|
||||||
updateJobSchedule = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.RetryInterval != nil {
|
|
||||||
schedule.ScriptExecutionJob.RetryInterval = *payload.RetryInterval
|
|
||||||
updateJobSchedule = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return updateJobSchedule
|
|
||||||
}
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
"github.com/portainer/portainer/api"
|
"github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
|
"github.com/portainer/portainer/api/internal/snapshot"
|
||||||
)
|
)
|
||||||
|
|
||||||
func hideFields(settings *portainer.Settings) {
|
func hideFields(settings *portainer.Settings) {
|
||||||
|
@ -21,9 +22,9 @@ type Handler struct {
|
||||||
AuthorizationService *authorization.Service
|
AuthorizationService *authorization.Service
|
||||||
DataStore portainer.DataStore
|
DataStore portainer.DataStore
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
JobScheduler portainer.JobScheduler
|
|
||||||
JWTService portainer.JWTService
|
JWTService portainer.JWTService
|
||||||
LDAPService portainer.LDAPService
|
LDAPService portainer.LDAPService
|
||||||
|
SnapshotService *snapshot.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage settings operations.
|
// NewHandler creates a handler to manage settings operations.
|
||||||
|
|
|
@ -186,26 +186,11 @@ func (handler *Handler) updateVolumeBrowserSetting(settings *portainer.Settings)
|
||||||
func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, snapshotInterval string) error {
|
func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, snapshotInterval string) error {
|
||||||
settings.SnapshotInterval = snapshotInterval
|
settings.SnapshotInterval = snapshotInterval
|
||||||
|
|
||||||
schedules, err := handler.DataStore.Schedule().SchedulesByJobType(portainer.SnapshotJobType)
|
err := handler.SnapshotService.SetSnapshotInterval(snapshotInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(schedules) != 0 {
|
|
||||||
snapshotSchedule := schedules[0]
|
|
||||||
snapshotSchedule.CronExpression = "@every " + snapshotInterval
|
|
||||||
|
|
||||||
err := handler.JobScheduler.UpdateSystemJobSchedule(portainer.SnapshotJobType, snapshotSchedule.CronExpression)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = handler.DataStore.Schedule().UpdateSchedule(snapshotSchedule.ID, &snapshotSchedule)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -362,3 +362,22 @@ func (bouncer *RequestBouncer) newRestrictedContextRequest(userID portainer.User
|
||||||
|
|
||||||
return requestContext, nil
|
return requestContext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EdgeComputeOperation defines a restriced edge compute operation.
|
||||||
|
// Use of this operation will only be authorized if edgeCompute is enabled in settings
|
||||||
|
func (bouncer *RequestBouncer) EdgeComputeOperation(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
settings, err := bouncer.dataStore.Settings().Settings()
|
||||||
|
if err != nil {
|
||||||
|
httperror.WriteError(w, http.StatusServiceUnavailable, "Unable to retrieve settings", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !settings.EnableEdgeComputeFeatures {
|
||||||
|
httperror.WriteError(w, http.StatusServiceUnavailable, "Edge compute features are disabled", errors.New("Edge compute features are disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
"github.com/portainer/portainer/api/http/handler/endpointedge"
|
||||||
"github.com/portainer/portainer/api/http/handler/support"
|
"github.com/portainer/portainer/api/http/handler/support"
|
||||||
|
"github.com/portainer/portainer/api/internal/snapshot"
|
||||||
|
|
||||||
"github.com/portainer/portainer/api/http/handler/roles"
|
"github.com/portainer/portainer/api/http/handler/roles"
|
||||||
|
|
||||||
|
@ -16,6 +17,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/handler"
|
"github.com/portainer/portainer/api/http/handler"
|
||||||
"github.com/portainer/portainer/api/http/handler/auth"
|
"github.com/portainer/portainer/api/http/handler/auth"
|
||||||
"github.com/portainer/portainer/api/http/handler/dockerhub"
|
"github.com/portainer/portainer/api/http/handler/dockerhub"
|
||||||
|
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
"github.com/portainer/portainer/api/http/handler/endpointgroups"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
||||||
"github.com/portainer/portainer/api/http/handler/endpoints"
|
"github.com/portainer/portainer/api/http/handler/endpoints"
|
||||||
|
@ -24,7 +26,6 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/handler/motd"
|
"github.com/portainer/portainer/api/http/handler/motd"
|
||||||
"github.com/portainer/portainer/api/http/handler/registries"
|
"github.com/portainer/portainer/api/http/handler/registries"
|
||||||
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
|
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
|
||||||
"github.com/portainer/portainer/api/http/handler/schedules"
|
|
||||||
"github.com/portainer/portainer/api/http/handler/settings"
|
"github.com/portainer/portainer/api/http/handler/settings"
|
||||||
"github.com/portainer/portainer/api/http/handler/stacks"
|
"github.com/portainer/portainer/api/http/handler/stacks"
|
||||||
"github.com/portainer/portainer/api/http/handler/status"
|
"github.com/portainer/portainer/api/http/handler/status"
|
||||||
|
@ -54,7 +55,7 @@ type Server struct {
|
||||||
ComposeStackManager portainer.ComposeStackManager
|
ComposeStackManager portainer.ComposeStackManager
|
||||||
CryptoService portainer.CryptoService
|
CryptoService portainer.CryptoService
|
||||||
SignatureService portainer.DigitalSignatureService
|
SignatureService portainer.DigitalSignatureService
|
||||||
JobScheduler portainer.JobScheduler
|
SnapshotService *snapshot.Service
|
||||||
Snapshotter portainer.Snapshotter
|
Snapshotter portainer.Snapshotter
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
DataStore portainer.DataStore
|
DataStore portainer.DataStore
|
||||||
|
@ -67,7 +68,6 @@ type Server struct {
|
||||||
SSLCert string
|
SSLCert string
|
||||||
SSLKey string
|
SSLKey string
|
||||||
DockerClientFactory *docker.ClientFactory
|
DockerClientFactory *docker.ClientFactory
|
||||||
JobService portainer.JobService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start starts the HTTP server
|
// Start starts the HTTP server
|
||||||
|
@ -98,6 +98,11 @@ func (server *Server) Start() error {
|
||||||
var edgeGroupsHandler = edgegroups.NewHandler(requestBouncer)
|
var edgeGroupsHandler = edgegroups.NewHandler(requestBouncer)
|
||||||
edgeGroupsHandler.DataStore = server.DataStore
|
edgeGroupsHandler.DataStore = server.DataStore
|
||||||
|
|
||||||
|
var edgeJobsHandler = edgejobs.NewHandler(requestBouncer)
|
||||||
|
edgeJobsHandler.DataStore = server.DataStore
|
||||||
|
edgeJobsHandler.FileService = server.FileService
|
||||||
|
edgeJobsHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||||
|
|
||||||
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer)
|
var edgeStacksHandler = edgestacks.NewHandler(requestBouncer)
|
||||||
edgeStacksHandler.DataStore = server.DataStore
|
edgeStacksHandler.DataStore = server.DataStore
|
||||||
edgeStacksHandler.FileService = server.FileService
|
edgeStacksHandler.FileService = server.FileService
|
||||||
|
@ -110,7 +115,6 @@ func (server *Server) Start() error {
|
||||||
endpointHandler.DataStore = server.DataStore
|
endpointHandler.DataStore = server.DataStore
|
||||||
endpointHandler.AuthorizationService = authorizationService
|
endpointHandler.AuthorizationService = authorizationService
|
||||||
endpointHandler.FileService = server.FileService
|
endpointHandler.FileService = server.FileService
|
||||||
endpointHandler.JobService = server.JobService
|
|
||||||
endpointHandler.ProxyManager = proxyManager
|
endpointHandler.ProxyManager = proxyManager
|
||||||
endpointHandler.ReverseTunnelService = server.ReverseTunnelService
|
endpointHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||||
endpointHandler.Snapshotter = server.Snapshotter
|
endpointHandler.Snapshotter = server.Snapshotter
|
||||||
|
@ -118,6 +122,7 @@ func (server *Server) Start() error {
|
||||||
var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer)
|
var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer)
|
||||||
endpointEdgeHandler.DataStore = server.DataStore
|
endpointEdgeHandler.DataStore = server.DataStore
|
||||||
endpointEdgeHandler.FileService = server.FileService
|
endpointEdgeHandler.FileService = server.FileService
|
||||||
|
endpointEdgeHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||||
|
|
||||||
var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer)
|
var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer)
|
||||||
endpointGroupHandler.DataStore = server.DataStore
|
endpointGroupHandler.DataStore = server.DataStore
|
||||||
|
@ -145,20 +150,13 @@ func (server *Server) Start() error {
|
||||||
var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer)
|
var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer)
|
||||||
resourceControlHandler.DataStore = server.DataStore
|
resourceControlHandler.DataStore = server.DataStore
|
||||||
|
|
||||||
var schedulesHandler = schedules.NewHandler(requestBouncer)
|
|
||||||
schedulesHandler.DataStore = server.DataStore
|
|
||||||
schedulesHandler.FileService = server.FileService
|
|
||||||
schedulesHandler.JobService = server.JobService
|
|
||||||
schedulesHandler.JobScheduler = server.JobScheduler
|
|
||||||
schedulesHandler.ReverseTunnelService = server.ReverseTunnelService
|
|
||||||
|
|
||||||
var settingsHandler = settings.NewHandler(requestBouncer)
|
var settingsHandler = settings.NewHandler(requestBouncer)
|
||||||
settingsHandler.AuthorizationService = authorizationService
|
settingsHandler.AuthorizationService = authorizationService
|
||||||
settingsHandler.DataStore = server.DataStore
|
settingsHandler.DataStore = server.DataStore
|
||||||
settingsHandler.FileService = server.FileService
|
settingsHandler.FileService = server.FileService
|
||||||
settingsHandler.JobScheduler = server.JobScheduler
|
|
||||||
settingsHandler.JWTService = server.JWTService
|
settingsHandler.JWTService = server.JWTService
|
||||||
settingsHandler.LDAPService = server.LDAPService
|
settingsHandler.LDAPService = server.LDAPService
|
||||||
|
settingsHandler.SnapshotService = server.SnapshotService
|
||||||
|
|
||||||
var stackHandler = stacks.NewHandler(requestBouncer)
|
var stackHandler = stacks.NewHandler(requestBouncer)
|
||||||
stackHandler.DataStore = server.DataStore
|
stackHandler.DataStore = server.DataStore
|
||||||
|
@ -207,6 +205,7 @@ func (server *Server) Start() error {
|
||||||
AuthHandler: authHandler,
|
AuthHandler: authHandler,
|
||||||
DockerHubHandler: dockerHubHandler,
|
DockerHubHandler: dockerHubHandler,
|
||||||
EdgeGroupsHandler: edgeGroupsHandler,
|
EdgeGroupsHandler: edgeGroupsHandler,
|
||||||
|
EdgeJobsHandler: edgeJobsHandler,
|
||||||
EdgeStacksHandler: edgeStacksHandler,
|
EdgeStacksHandler: edgeStacksHandler,
|
||||||
EdgeTemplatesHandler: edgeTemplatesHandler,
|
EdgeTemplatesHandler: edgeTemplatesHandler,
|
||||||
EndpointGroupHandler: endpointGroupHandler,
|
EndpointGroupHandler: endpointGroupHandler,
|
||||||
|
@ -230,7 +229,6 @@ func (server *Server) Start() error {
|
||||||
UserHandler: userHandler,
|
UserHandler: userHandler,
|
||||||
WebSocketHandler: websocketHandler,
|
WebSocketHandler: websocketHandler,
|
||||||
WebhookHandler: webhookHandler,
|
WebhookHandler: webhookHandler,
|
||||||
SchedulesHanlder: schedulesHandler,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if server.SSL {
|
if server.SSL {
|
||||||
|
|
|
@ -0,0 +1,129 @@
|
||||||
|
package snapshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service repesents a service to manage system snapshots
|
||||||
|
type Service struct {
|
||||||
|
dataStore portainer.DataStore
|
||||||
|
refreshSignal chan struct{}
|
||||||
|
snapshotIntervalInSeconds float64
|
||||||
|
snapshotter portainer.Snapshotter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new instance of a service
|
||||||
|
func NewService(snapshotInterval string, dataStore portainer.DataStore, snapshotter portainer.Snapshotter) (*Service, error) {
|
||||||
|
snapshotFrequency, err := time.ParseDuration(snapshotInterval)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
dataStore: dataStore,
|
||||||
|
snapshotIntervalInSeconds: snapshotFrequency.Seconds(),
|
||||||
|
snapshotter: snapshotter,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the service
|
||||||
|
func (service *Service) Start() {
|
||||||
|
if service.refreshSignal != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
service.refreshSignal = make(chan struct{})
|
||||||
|
service.startSnapshotLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) stop() {
|
||||||
|
if service.refreshSignal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
close(service.refreshSignal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSnapshotInterval sets the snapshot interval and resets the service
|
||||||
|
func (service *Service) SetSnapshotInterval(snapshotInterval string) error {
|
||||||
|
service.stop()
|
||||||
|
|
||||||
|
snapshotFrequency, err := time.ParseDuration(snapshotInterval)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
service.snapshotIntervalInSeconds = snapshotFrequency.Seconds()
|
||||||
|
|
||||||
|
service.Start()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) startSnapshotLoop() error {
|
||||||
|
ticker := time.NewTicker(time.Duration(service.snapshotIntervalInSeconds) * time.Second)
|
||||||
|
go func() {
|
||||||
|
err := service.snapshotEndpoints()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ERROR] [internal,snapshot] [message: background schedule error (endpoint snapshot).] [error: %s]", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
err := service.snapshotEndpoints()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ERROR] [internal,snapshot] [message: background schedule error (endpoint snapshot).] [error: %s]", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-service.refreshSignal:
|
||||||
|
log.Println("[DEBUG] [internal,snapshot] [message: shutting down Snapshot service]")
|
||||||
|
ticker.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) snapshotEndpoints() error {
|
||||||
|
endpoints, err := service.dataStore.Endpoint().Endpoints()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
if endpoint.Type == portainer.EdgeAgentEnvironment {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, snapshotError := service.snapshotter.CreateSnapshot(&endpoint)
|
||||||
|
|
||||||
|
latestEndpointReference, err := service.dataStore.Endpoint().Endpoint(endpoint.ID)
|
||||||
|
if latestEndpointReference == nil {
|
||||||
|
log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
latestEndpointReference.Status = portainer.EndpointStatusUp
|
||||||
|
if snapshotError != nil {
|
||||||
|
log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError)
|
||||||
|
latestEndpointReference.Status = portainer.EndpointStatusDown
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot != nil {
|
||||||
|
latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = service.dataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("background schedule error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
161
api/portainer.go
161
api/portainer.go
|
@ -76,6 +76,7 @@ type (
|
||||||
|
|
||||||
DockerHub() DockerHubService
|
DockerHub() DockerHubService
|
||||||
EdgeGroup() EdgeGroupService
|
EdgeGroup() EdgeGroupService
|
||||||
|
EdgeJob() EdgeJobService
|
||||||
EdgeStack() EdgeStackService
|
EdgeStack() EdgeStackService
|
||||||
Endpoint() EndpointService
|
Endpoint() EndpointService
|
||||||
EndpointGroup() EndpointGroupService
|
EndpointGroup() EndpointGroupService
|
||||||
|
@ -84,7 +85,6 @@ type (
|
||||||
Registry() RegistryService
|
Registry() RegistryService
|
||||||
ResourceControl() ResourceControlService
|
ResourceControl() ResourceControlService
|
||||||
Role() RoleService
|
Role() RoleService
|
||||||
Schedule() ScheduleService
|
|
||||||
Settings() SettingsService
|
Settings() SettingsService
|
||||||
Stack() StackService
|
Stack() StackService
|
||||||
Tag() TagService
|
Tag() TagService
|
||||||
|
@ -117,7 +117,32 @@ type (
|
||||||
// EdgeGroupID represents an Edge group identifier
|
// EdgeGroupID represents an Edge group identifier
|
||||||
EdgeGroupID int
|
EdgeGroupID int
|
||||||
|
|
||||||
|
// EdgeJob represents a job that can run on Edge environments.
|
||||||
|
EdgeJob struct {
|
||||||
|
ID EdgeJobID `json:"Id"`
|
||||||
|
Created int64 `json:"Created"`
|
||||||
|
CronExpression string `json:"CronExpression"`
|
||||||
|
Endpoints map[EndpointID]EdgeJobEndpointMeta `json:"Endpoints"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
ScriptPath string `json:"ScriptPath"`
|
||||||
|
Recurring bool `json:"Recurring"`
|
||||||
|
Version int `json:"Version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EdgeJobEndpointMeta represents a meta data object for an Edge job and Endpoint relation
|
||||||
|
EdgeJobEndpointMeta struct {
|
||||||
|
LogsStatus EdgeJobLogsStatus
|
||||||
|
CollectLogs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// EdgeJobID represents an Edge job identifier
|
||||||
|
EdgeJobID int
|
||||||
|
|
||||||
|
// EdgeJobLogsStatus represent status of logs collection job
|
||||||
|
EdgeJobLogsStatus int
|
||||||
|
|
||||||
// EdgeSchedule represents a scheduled job that can run on Edge environments.
|
// EdgeSchedule represents a scheduled job that can run on Edge environments.
|
||||||
|
// Deprecated in favor of EdgeJob
|
||||||
EdgeSchedule struct {
|
EdgeSchedule struct {
|
||||||
ID ScheduleID `json:"Id"`
|
ID ScheduleID `json:"Id"`
|
||||||
CronExpression string `json:"CronExpression"`
|
CronExpression string `json:"CronExpression"`
|
||||||
|
@ -417,22 +442,19 @@ type (
|
||||||
// It only contains a pointer to one of the JobRunner implementations
|
// It only contains a pointer to one of the JobRunner implementations
|
||||||
// based on the JobType.
|
// based on the JobType.
|
||||||
// NOTE: The Recurring option is only used by ScriptExecutionJob at the moment
|
// NOTE: The Recurring option is only used by ScriptExecutionJob at the moment
|
||||||
|
// Deprecated in favor of EdgeJob
|
||||||
Schedule struct {
|
Schedule struct {
|
||||||
ID ScheduleID `json:"Id"`
|
ID ScheduleID `json:"Id"`
|
||||||
Name string
|
Name string
|
||||||
CronExpression string
|
CronExpression string
|
||||||
Recurring bool
|
Recurring bool
|
||||||
Created int64
|
Created int64
|
||||||
JobType JobType
|
JobType JobType
|
||||||
EdgeSchedule *EdgeSchedule
|
EdgeSchedule *EdgeSchedule
|
||||||
ScriptExecutionJob *ScriptExecutionJob
|
|
||||||
SnapshotJob *SnapshotJob
|
|
||||||
|
|
||||||
// Deprecated fields
|
|
||||||
EndpointSyncJob *EndpointSyncJob
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScheduleID represents a schedule identifier.
|
// ScheduleID represents a schedule identifier.
|
||||||
|
// Deprecated in favor of EdgeJob
|
||||||
ScheduleID int
|
ScheduleID int
|
||||||
|
|
||||||
// ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container
|
// ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container
|
||||||
|
@ -484,9 +506,6 @@ type (
|
||||||
SnapshotRaw SnapshotRaw `json:"SnapshotRaw"`
|
SnapshotRaw SnapshotRaw `json:"SnapshotRaw"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SnapshotJob represents a scheduled job that can create endpoint snapshots
|
|
||||||
SnapshotJob struct{}
|
|
||||||
|
|
||||||
// SnapshotRaw represents all the information related to a snapshot as returned by the Docker API
|
// SnapshotRaw represents all the information related to a snapshot as returned by the Docker API
|
||||||
SnapshotRaw struct {
|
SnapshotRaw struct {
|
||||||
Containers interface{} `json:"Containers"`
|
Containers interface{} `json:"Containers"`
|
||||||
|
@ -664,7 +683,7 @@ type (
|
||||||
Status string
|
Status string
|
||||||
LastActivity time.Time
|
LastActivity time.Time
|
||||||
Port int
|
Port int
|
||||||
Schedules []EdgeSchedule
|
Jobs []EdgeJob
|
||||||
Credentials string
|
Credentials string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -741,6 +760,35 @@ type (
|
||||||
UpdateDockerHub(registry *DockerHub) error
|
UpdateDockerHub(registry *DockerHub) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EdgeGroupService represents a service to manage Edge groups
|
||||||
|
EdgeGroupService interface {
|
||||||
|
EdgeGroups() ([]EdgeGroup, error)
|
||||||
|
EdgeGroup(ID EdgeGroupID) (*EdgeGroup, error)
|
||||||
|
CreateEdgeGroup(group *EdgeGroup) error
|
||||||
|
UpdateEdgeGroup(ID EdgeGroupID, group *EdgeGroup) error
|
||||||
|
DeleteEdgeGroup(ID EdgeGroupID) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// EdgeJobService represents a service to manage Edge jobs
|
||||||
|
EdgeJobService interface {
|
||||||
|
EdgeJobs() ([]EdgeJob, error)
|
||||||
|
EdgeJob(ID EdgeJobID) (*EdgeJob, error)
|
||||||
|
CreateEdgeJob(edgeJob *EdgeJob) error
|
||||||
|
UpdateEdgeJob(ID EdgeJobID, edgeJob *EdgeJob) error
|
||||||
|
DeleteEdgeJob(ID EdgeJobID) error
|
||||||
|
GetNextIdentifier() int
|
||||||
|
}
|
||||||
|
|
||||||
|
// EdgeStackService represents a service to manage Edge stacks
|
||||||
|
EdgeStackService interface {
|
||||||
|
EdgeStacks() ([]EdgeStack, error)
|
||||||
|
EdgeStack(ID EdgeStackID) (*EdgeStack, error)
|
||||||
|
CreateEdgeStack(edgeStack *EdgeStack) error
|
||||||
|
UpdateEdgeStack(ID EdgeStackID, edgeStack *EdgeStack) error
|
||||||
|
DeleteEdgeStack(ID EdgeStackID) error
|
||||||
|
GetNextIdentifier() int
|
||||||
|
}
|
||||||
|
|
||||||
// EndpointService represents a service for managing endpoint data
|
// EndpointService represents a service for managing endpoint data
|
||||||
EndpointService interface {
|
EndpointService interface {
|
||||||
Endpoint(ID EndpointID) (*Endpoint, error)
|
Endpoint(ID EndpointID) (*Endpoint, error)
|
||||||
|
@ -806,8 +854,11 @@ type (
|
||||||
LoadKeyPair() ([]byte, []byte, error)
|
LoadKeyPair() ([]byte, []byte, error)
|
||||||
WriteJSONToFile(path string, content interface{}) error
|
WriteJSONToFile(path string, content interface{}) error
|
||||||
FileExists(path string) (bool, error)
|
FileExists(path string) (bool, error)
|
||||||
StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error)
|
StoreEdgeJobFileFromBytes(identifier string, data []byte) (string, error)
|
||||||
GetScheduleFolder(identifier string) string
|
GetEdgeJobFolder(identifier string) string
|
||||||
|
ClearEdgeJobTaskLogs(edgeJobID, taskID string) error
|
||||||
|
GetEdgeJobTaskLogFileContent(edgeJobID, taskID string) (string, error)
|
||||||
|
StoreEdgeJobTaskLogFileFromBytes(edgeJobID, taskID string, data []byte) error
|
||||||
ExtractExtensionArchive(data []byte) error
|
ExtractExtensionArchive(data []byte) error
|
||||||
GetBinaryFolder() string
|
GetBinaryFolder() string
|
||||||
}
|
}
|
||||||
|
@ -818,26 +869,6 @@ type (
|
||||||
ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error
|
ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// JobRunner represents a service that can be used to run a job
|
|
||||||
JobRunner interface {
|
|
||||||
Run()
|
|
||||||
GetSchedule() *Schedule
|
|
||||||
}
|
|
||||||
|
|
||||||
// JobScheduler represents a service to run jobs on a periodic basis
|
|
||||||
JobScheduler interface {
|
|
||||||
ScheduleJob(runner JobRunner) error
|
|
||||||
UpdateJobSchedule(runner JobRunner) error
|
|
||||||
UpdateSystemJobSchedule(jobType JobType, newCronExpression string) error
|
|
||||||
UnscheduleJob(ID ScheduleID)
|
|
||||||
Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// JobService represents a service to manage job execution on hosts
|
|
||||||
JobService interface {
|
|
||||||
ExecuteScript(endpoint *Endpoint, nodeName, image string, script []byte, schedule *Schedule) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWTService represents a service for managing JWT tokens
|
// JWTService represents a service for managing JWT tokens
|
||||||
JWTService interface {
|
JWTService interface {
|
||||||
GenerateToken(data *TokenData) (string, error)
|
GenerateToken(data *TokenData) (string, error)
|
||||||
|
@ -879,8 +910,8 @@ type (
|
||||||
SetTunnelStatusToRequired(endpointID EndpointID) error
|
SetTunnelStatusToRequired(endpointID EndpointID) error
|
||||||
SetTunnelStatusToIdle(endpointID EndpointID)
|
SetTunnelStatusToIdle(endpointID EndpointID)
|
||||||
GetTunnelDetails(endpointID EndpointID) *TunnelDetails
|
GetTunnelDetails(endpointID EndpointID) *TunnelDetails
|
||||||
AddSchedule(endpointID EndpointID, schedule *EdgeSchedule)
|
AddEdgeJob(endpointID EndpointID, edgeJob *EdgeJob)
|
||||||
RemoveSchedule(scheduleID ScheduleID)
|
RemoveEdgeJob(edgeJobID EdgeJobID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoleService represents a service for managing user roles
|
// RoleService represents a service for managing user roles
|
||||||
|
@ -891,17 +922,6 @@ type (
|
||||||
UpdateRole(ID RoleID, role *Role) error
|
UpdateRole(ID RoleID, role *Role) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScheduleService represents a service for managing schedule data
|
|
||||||
ScheduleService interface {
|
|
||||||
Schedule(ID ScheduleID) (*Schedule, error)
|
|
||||||
Schedules() ([]Schedule, error)
|
|
||||||
SchedulesByJobType(jobType JobType) ([]Schedule, error)
|
|
||||||
CreateSchedule(schedule *Schedule) error
|
|
||||||
UpdateSchedule(ID ScheduleID, schedule *Schedule) error
|
|
||||||
DeleteSchedule(ID ScheduleID) error
|
|
||||||
GetNextIdentifier() int
|
|
||||||
}
|
|
||||||
|
|
||||||
// SettingsService represents a service for managing application settings
|
// SettingsService represents a service for managing application settings
|
||||||
SettingsService interface {
|
SettingsService interface {
|
||||||
Settings() (*Settings, error)
|
Settings() (*Settings, error)
|
||||||
|
@ -1001,25 +1021,6 @@ type (
|
||||||
WebhookByToken(token string) (*Webhook, error)
|
WebhookByToken(token string) (*Webhook, error)
|
||||||
DeleteWebhook(serviceID WebhookID) error
|
DeleteWebhook(serviceID WebhookID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// EdgeGroupService represents a service to manage Edge groups
|
|
||||||
EdgeGroupService interface {
|
|
||||||
EdgeGroups() ([]EdgeGroup, error)
|
|
||||||
EdgeGroup(ID EdgeGroupID) (*EdgeGroup, error)
|
|
||||||
CreateEdgeGroup(group *EdgeGroup) error
|
|
||||||
UpdateEdgeGroup(ID EdgeGroupID, group *EdgeGroup) error
|
|
||||||
DeleteEdgeGroup(ID EdgeGroupID) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// EdgeStackService represents a service to manage Edge stacks
|
|
||||||
EdgeStackService interface {
|
|
||||||
EdgeStacks() ([]EdgeStack, error)
|
|
||||||
EdgeStack(ID EdgeStackID) (*EdgeStack, error)
|
|
||||||
CreateEdgeStack(edgeStack *EdgeStack) error
|
|
||||||
UpdateEdgeStack(ID EdgeStackID, edgeStack *EdgeStack) error
|
|
||||||
DeleteEdgeStack(ID EdgeStackID) error
|
|
||||||
GetNextIdentifier() int
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -1072,6 +1073,16 @@ const (
|
||||||
AuthenticationOAuth
|
AuthenticationOAuth
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
_ EdgeJobLogsStatus = iota
|
||||||
|
// EdgeJobLogsStatusIdle represents an idle log collection job
|
||||||
|
EdgeJobLogsStatusIdle
|
||||||
|
// EdgeJobLogsStatusPending represents a pending log collection job
|
||||||
|
EdgeJobLogsStatusPending
|
||||||
|
// EdgeJobLogsStatusCollected represents a completed log collection job
|
||||||
|
EdgeJobLogsStatusCollected
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
_ EdgeStackStatusType = iota
|
_ EdgeStackStatusType = iota
|
||||||
//StatusOk represents a successfully deployed edge stack
|
//StatusOk represents a successfully deployed edge stack
|
||||||
|
@ -1120,14 +1131,8 @@ const (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
_ JobType = iota
|
_ JobType = iota
|
||||||
// ScriptExecutionJobType is a non-system job used to execute a script against a list of
|
|
||||||
// endpoints via privileged containers
|
|
||||||
ScriptExecutionJobType
|
|
||||||
// SnapshotJobType is a system job used to create endpoint snapshots
|
// SnapshotJobType is a system job used to create endpoint snapshots
|
||||||
SnapshotJobType
|
SnapshotJobType = 2
|
||||||
// EndpointSyncJobType is a system job used to synchronize endpoints from
|
|
||||||
// an external definition store (Deprecated)
|
|
||||||
EndpointSyncJobType
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -3,6 +3,7 @@ angular
|
||||||
.constant('API_ENDPOINT_AUTH', 'api/auth')
|
.constant('API_ENDPOINT_AUTH', 'api/auth')
|
||||||
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
|
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
|
||||||
.constant('API_ENDPOINT_EDGE_GROUPS', 'api/edge_groups')
|
.constant('API_ENDPOINT_EDGE_GROUPS', 'api/edge_groups')
|
||||||
|
.constant('API_ENDPOINT_EDGE_JOBS', 'api/edge_jobs')
|
||||||
.constant('API_ENDPOINT_EDGE_STACKS', 'api/edge_stacks')
|
.constant('API_ENDPOINT_EDGE_STACKS', 'api/edge_stacks')
|
||||||
.constant('API_ENDPOINT_EDGE_TEMPLATES', 'api/edge_templates')
|
.constant('API_ENDPOINT_EDGE_TEMPLATES', 'api/edge_templates')
|
||||||
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
|
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
|
||||||
|
@ -11,7 +12,6 @@ angular
|
||||||
.constant('API_ENDPOINT_EXTENSIONS', 'api/extensions')
|
.constant('API_ENDPOINT_EXTENSIONS', 'api/extensions')
|
||||||
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
|
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
|
||||||
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
|
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
|
||||||
.constant('API_ENDPOINT_SCHEDULES', 'api/schedules')
|
|
||||||
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
|
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
|
||||||
.constant('API_ENDPOINT_STACKS', 'api/stacks')
|
.constant('API_ENDPOINT_STACKS', 'api/stacks')
|
||||||
.constant('API_ENDPOINT_STATUS', 'api/status')
|
.constant('API_ENDPOINT_STATUS', 'api/status')
|
||||||
|
|
|
@ -175,16 +175,6 @@ angular.module('portainer.docker', ['portainer.app']).config([
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
var hostJob = {
|
|
||||||
name: 'docker.host.job',
|
|
||||||
url: '/job',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
component: 'hostJobView',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
var events = {
|
var events = {
|
||||||
name: 'docker.events',
|
name: 'docker.events',
|
||||||
url: '/events',
|
url: '/events',
|
||||||
|
@ -299,16 +289,6 @@ angular.module('portainer.docker', ['portainer.app']).config([
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
var nodeJob = {
|
|
||||||
name: 'docker.nodes.node.job',
|
|
||||||
url: '/job',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
component: 'nodeJobView',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
var secrets = {
|
var secrets = {
|
||||||
name: 'docker.secrets',
|
name: 'docker.secrets',
|
||||||
url: '/secrets',
|
url: '/secrets',
|
||||||
|
@ -495,7 +475,6 @@ angular.module('portainer.docker', ['portainer.app']).config([
|
||||||
$stateRegistryProvider.register(dashboard);
|
$stateRegistryProvider.register(dashboard);
|
||||||
$stateRegistryProvider.register(host);
|
$stateRegistryProvider.register(host);
|
||||||
$stateRegistryProvider.register(hostBrowser);
|
$stateRegistryProvider.register(hostBrowser);
|
||||||
$stateRegistryProvider.register(hostJob);
|
|
||||||
$stateRegistryProvider.register(events);
|
$stateRegistryProvider.register(events);
|
||||||
$stateRegistryProvider.register(images);
|
$stateRegistryProvider.register(images);
|
||||||
$stateRegistryProvider.register(image);
|
$stateRegistryProvider.register(image);
|
||||||
|
@ -507,7 +486,6 @@ angular.module('portainer.docker', ['portainer.app']).config([
|
||||||
$stateRegistryProvider.register(nodes);
|
$stateRegistryProvider.register(nodes);
|
||||||
$stateRegistryProvider.register(node);
|
$stateRegistryProvider.register(node);
|
||||||
$stateRegistryProvider.register(nodeBrowser);
|
$stateRegistryProvider.register(nodeBrowser);
|
||||||
$stateRegistryProvider.register(nodeJob);
|
|
||||||
$stateRegistryProvider.register(secrets);
|
$stateRegistryProvider.register(secrets);
|
||||||
$stateRegistryProvider.register(secret);
|
$stateRegistryProvider.register(secret);
|
||||||
$stateRegistryProvider.register(secretCreation);
|
$stateRegistryProvider.register(secretCreation);
|
||||||
|
|
|
@ -1,130 +0,0 @@
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
|
|
||||||
<div class="datatable">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body classes="no-padding">
|
|
||||||
<div class="toolBar">
|
|
||||||
<div class="toolBarTitle">
|
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i>
|
|
||||||
{{ $ctrl.titleText }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="actionBar">
|
|
||||||
<button type="button" class="btn btn-sm btn-primary" ng-click="$ctrl.purgeAction()">
|
|
||||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Clear job history
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="searchBar">
|
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="searchInput"
|
|
||||||
ng-model="$ctrl.state.textFilter"
|
|
||||||
ng-change="$ctrl.onTextFilterChange()"
|
|
||||||
placeholder="Search..."
|
|
||||||
auto-focus
|
|
||||||
ng-model-options="{ debounce: 300 }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover table-filters nowrap-cells">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>
|
|
||||||
<a ng-click="$ctrl.changeOrderBy('Id')">
|
|
||||||
Id
|
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && !$ctrl.state.reverseOrder"></i>
|
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && $ctrl.state.reverseOrder"></i>
|
|
||||||
</a>
|
|
||||||
</th>
|
|
||||||
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open">
|
|
||||||
<a ng-click="$ctrl.changeOrderBy('Status')">
|
|
||||||
State
|
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && !$ctrl.state.reverseOrder"></i>
|
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && $ctrl.state.reverseOrder"></i>
|
|
||||||
</a>
|
|
||||||
<div>
|
|
||||||
<span uib-dropdown-toggle class="table-filter" ng-if="!$ctrl.filters.state.enabled"> Filter <i class="fa fa-filter" aria-hidden="true"></i></span>
|
|
||||||
<span uib-dropdown-toggle class="table-filter filter-active" ng-if="$ctrl.filters.state.enabled">Filter <i class="fa fa-check" aria-hidden="true"></i></span>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-menu" uib-dropdown-menu>
|
|
||||||
<div class="tableMenu">
|
|
||||||
<div class="menuHeader">
|
|
||||||
Filter by state
|
|
||||||
</div>
|
|
||||||
<div class="menuContent">
|
|
||||||
<div class="md-checkbox" ng-repeat="filter in $ctrl.filters.state.values track by $index">
|
|
||||||
<input id="filter_state_{{ $index }}" type="checkbox" ng-model="filter.display" ng-change="$ctrl.onStateFilterChange()" />
|
|
||||||
<label for="filter_state_{{ $index }}">{{ filter.label }}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.filters.state.open = false;">Close</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<a ng-click="$ctrl.changeOrderBy('Created')">
|
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && !$ctrl.state.reverseOrder"></i>
|
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i>
|
|
||||||
Created
|
|
||||||
</a>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
<a ui-sref="docker.containers.container.logs({ id: item.Id, nodeName: item.NodeName })" title="{{ item.Id }}"> {{ item | containername }}</a>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
ng-if="['starting', 'healthy', 'unhealthy'].indexOf(item.Status) !== -1"
|
|
||||||
class="label label-{{ item.Status | containerstatusbadge }} interactive"
|
|
||||||
uib-tooltip="This container has a health check"
|
|
||||||
>{{ item.Status }}</span
|
|
||||||
>
|
|
||||||
<span ng-if="['starting', 'healthy', 'unhealthy'].indexOf(item.Status) === -1" class="label label-{{ item.Status | containerstatusbadge }}">
|
|
||||||
{{ item.Status }}</span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ item.Created | getisodatefromtimestamp }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="!$ctrl.dataset">
|
|
||||||
<td colspan="9" class="text-center text-muted">Loading...</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
|
||||||
<td colspan="9" class="text-center text-muted">No jobs available.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="footer" ng-if="$ctrl.dataset">
|
|
||||||
<div class="paginationControls">
|
|
||||||
<form class="form-inline">
|
|
||||||
<span class="limitSelector">
|
|
||||||
<span style="margin-right: 5px;">
|
|
||||||
Items per page
|
|
||||||
</span>
|
|
||||||
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
|
|
||||||
<option value="0">All</option>
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="25">25</option>
|
|
||||||
<option value="50">50</option>
|
|
||||||
<option value="100">100</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<dir-pagination-controls max-size="5"></dir-pagination-controls>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,12 +0,0 @@
|
||||||
angular.module('portainer.docker').component('jobsDatatable', {
|
|
||||||
templateUrl: './jobsDatatable.html',
|
|
||||||
controller: 'JobsDatatableController',
|
|
||||||
bindings: {
|
|
||||||
titleText: '@',
|
|
||||||
titleIcon: '@',
|
|
||||||
dataset: '<',
|
|
||||||
tableKey: '@',
|
|
||||||
orderBy: '@',
|
|
||||||
reverseOrder: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,150 +0,0 @@
|
||||||
import _ from 'lodash-es';
|
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('JobsDatatableController', [
|
|
||||||
'$scope',
|
|
||||||
'$controller',
|
|
||||||
'$q',
|
|
||||||
'$state',
|
|
||||||
'PaginationService',
|
|
||||||
'DatatableService',
|
|
||||||
'ContainerService',
|
|
||||||
'ModalService',
|
|
||||||
'Notifications',
|
|
||||||
function ($scope, $controller, $q, $state, PaginationService, DatatableService, ContainerService, ModalService, Notifications) {
|
|
||||||
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
|
|
||||||
|
|
||||||
var ctrl = this;
|
|
||||||
|
|
||||||
this.filters = {
|
|
||||||
state: {
|
|
||||||
open: false,
|
|
||||||
enabled: false,
|
|
||||||
values: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.applyFilters = function (value) {
|
|
||||||
var container = value;
|
|
||||||
var filters = ctrl.filters;
|
|
||||||
for (var i = 0; i < filters.state.values.length; i++) {
|
|
||||||
var filter = filters.state.values[i];
|
|
||||||
if (container.Status === filter.label && filter.display) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.onStateFilterChange = function () {
|
|
||||||
var filters = this.filters.state.values;
|
|
||||||
var filtered = false;
|
|
||||||
for (var i = 0; i < filters.length; i++) {
|
|
||||||
var filter = filters[i];
|
|
||||||
if (!filter.display) {
|
|
||||||
filtered = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.filters.state.enabled = filtered;
|
|
||||||
DatatableService.setDataTableFilters(this.tableKey, this.filters);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.prepareTableFromDataset = function () {
|
|
||||||
var availableStateFilters = [];
|
|
||||||
for (var i = 0; i < this.dataset.length; i++) {
|
|
||||||
var item = this.dataset[i];
|
|
||||||
availableStateFilters.push({
|
|
||||||
label: item.Status,
|
|
||||||
display: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.filters.state.values = _.uniqBy(availableStateFilters, 'label');
|
|
||||||
};
|
|
||||||
|
|
||||||
this.updateStoredFilters = function (storedFilters) {
|
|
||||||
var datasetFilters = this.filters.state.values;
|
|
||||||
|
|
||||||
for (var i = 0; i < datasetFilters.length; i++) {
|
|
||||||
var filter = datasetFilters[i];
|
|
||||||
var existingFilter = _.find(storedFilters, ['label', filter.label]);
|
|
||||||
if (existingFilter && !existingFilter.display) {
|
|
||||||
filter.display = existingFilter.display;
|
|
||||||
this.filters.state.enabled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function confirmPurgeJobs() {
|
|
||||||
return showConfirmationModal();
|
|
||||||
|
|
||||||
function showConfirmationModal() {
|
|
||||||
var deferred = $q.defer();
|
|
||||||
|
|
||||||
ModalService.confirm({
|
|
||||||
title: 'Are you sure ?',
|
|
||||||
message: 'Clearing job history will remove all stopped jobs containers.',
|
|
||||||
buttons: {
|
|
||||||
confirm: {
|
|
||||||
label: 'Purge',
|
|
||||||
className: 'btn-danger',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
callback: function onConfirm(confirmed) {
|
|
||||||
deferred.resolve(confirmed);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return deferred.promise;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.purgeAction = function () {
|
|
||||||
confirmPurgeJobs().then(function success(confirmed) {
|
|
||||||
if (!confirmed) {
|
|
||||||
return $q.when();
|
|
||||||
}
|
|
||||||
ContainerService.prune({ label: ['io.portainer.job.endpoint'] })
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Success', 'Job history cleared');
|
|
||||||
$state.reload();
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err.message, 'Unable to clear job history');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
this.$onInit = function () {
|
|
||||||
this.setDefaults();
|
|
||||||
this.prepareTableFromDataset();
|
|
||||||
|
|
||||||
this.state.orderBy = this.orderBy;
|
|
||||||
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
|
|
||||||
if (storedOrder !== null) {
|
|
||||||
this.state.reverseOrder = storedOrder.reverse;
|
|
||||||
this.state.orderBy = storedOrder.orderBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
|
|
||||||
if (textFilter !== null) {
|
|
||||||
this.state.textFilter = textFilter;
|
|
||||||
this.onTextFilterChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
|
|
||||||
if (storedFilters !== null) {
|
|
||||||
this.filters = storedFilters;
|
|
||||||
this.updateStoredFilters(storedFilters.state.values);
|
|
||||||
}
|
|
||||||
if (this.filters && this.filters.state) {
|
|
||||||
this.filters.state.open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
|
|
||||||
if (storedSettings !== null) {
|
|
||||||
this.settings = storedSettings;
|
|
||||||
this.settings.open = false;
|
|
||||||
}
|
|
||||||
this.onSettingsRepeaterChange();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -13,22 +13,10 @@
|
||||||
host="$ctrl.hostDetails"
|
host="$ctrl.hostDetails"
|
||||||
is-browse-enabled="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && !$ctrl.offlineMode && $ctrl.hostFeaturesEnabled"
|
is-browse-enabled="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && !$ctrl.offlineMode && $ctrl.hostFeaturesEnabled"
|
||||||
browse-url="{{ $ctrl.browseUrl }}"
|
browse-url="{{ $ctrl.browseUrl }}"
|
||||||
is-job-enabled="$ctrl.isJobEnabled && !$ctrl.offlineMode && $ctrl.hostFeaturesEnabled"
|
|
||||||
job-url="{{ $ctrl.jobUrl }}"
|
|
||||||
></host-details-panel>
|
></host-details-panel>
|
||||||
|
|
||||||
<engine-details-panel engine="$ctrl.engineDetails"></engine-details-panel>
|
<engine-details-panel engine="$ctrl.engineDetails"></engine-details-panel>
|
||||||
|
|
||||||
<jobs-datatable
|
|
||||||
ng-if="$ctrl.isJobEnabled && $ctrl.jobs && !$ctrl.offlineMode && $ctrl.hostFeaturesEnabled"
|
|
||||||
title-text="Jobs"
|
|
||||||
title-icon="fa-tasks"
|
|
||||||
dataset="$ctrl.jobs"
|
|
||||||
table-key="jobs"
|
|
||||||
order-by="Created"
|
|
||||||
reverse-order="true"
|
|
||||||
></jobs-datatable>
|
|
||||||
|
|
||||||
<devices-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && !$ctrl.offlineMode && $ctrl.hostFeaturesEnabled" devices="$ctrl.devices"></devices-panel>
|
<devices-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && !$ctrl.offlineMode && $ctrl.hostFeaturesEnabled" devices="$ctrl.devices"></devices-panel>
|
||||||
<disks-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && !$ctrl.offlineMode && $ctrl.hostFeaturesEnabled" disks="$ctrl.disks"></disks-panel>
|
<disks-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1 && !$ctrl.offlineMode && $ctrl.hostFeaturesEnabled" disks="$ctrl.disks"></disks-panel>
|
||||||
|
|
||||||
|
|
|
@ -10,10 +10,7 @@ angular.module('portainer.docker').component('hostOverview', {
|
||||||
agentApiVersion: '<',
|
agentApiVersion: '<',
|
||||||
refreshUrl: '@',
|
refreshUrl: '@',
|
||||||
browseUrl: '@',
|
browseUrl: '@',
|
||||||
jobUrl: '@',
|
|
||||||
isJobEnabled: '<',
|
|
||||||
hostFeaturesEnabled: '<',
|
hostFeaturesEnabled: '<',
|
||||||
jobs: '<',
|
|
||||||
},
|
},
|
||||||
transclude: true,
|
transclude: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -25,14 +25,11 @@
|
||||||
<td>Total memory</td>
|
<td>Total memory</td>
|
||||||
<td>{{ $ctrl.host.totalMemory | humansize }}</td>
|
<td>{{ $ctrl.host.totalMemory | humansize }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr ng-if="$ctrl.isBrowseEnabled || $ctrl.isJobEnabled">
|
<tr ng-if="$ctrl.isBrowseEnabled">
|
||||||
<td colspan="2">
|
<td colspan="2">
|
||||||
<button class="btn btn-primary btn-sm" title="Browse" ng-if="$ctrl.isBrowseEnabled" ui-sref="{{ $ctrl.browseUrl }}">
|
<button class="btn btn-primary btn-sm" title="Browse" ui-sref="{{ $ctrl.browseUrl }}">
|
||||||
Browse
|
Browse
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary btn-sm" title="Execute job" ng-if="$ctrl.isJobEnabled" ui-sref="{{ $ctrl.jobUrl }}">
|
|
||||||
Execute job
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -2,9 +2,7 @@ angular.module('portainer.docker').component('hostDetailsPanel', {
|
||||||
templateUrl: './host-details-panel.html',
|
templateUrl: './host-details-panel.html',
|
||||||
bindings: {
|
bindings: {
|
||||||
host: '<',
|
host: '<',
|
||||||
isJobEnabled: '<',
|
|
||||||
isBrowseEnabled: '<',
|
isBrowseEnabled: '<',
|
||||||
browseUrl: '@',
|
browseUrl: '@',
|
||||||
jobUrl: '@',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
angular.module('portainer.docker').controller('HostJobController', [
|
|
||||||
'SystemService',
|
|
||||||
'Notifications',
|
|
||||||
function HostJobController(SystemService, Notifications) {
|
|
||||||
var ctrl = this;
|
|
||||||
ctrl.$onInit = $onInit;
|
|
||||||
|
|
||||||
function $onInit() {
|
|
||||||
SystemService.info()
|
|
||||||
.then(function onInfoLoaded(host) {
|
|
||||||
ctrl.host = host;
|
|
||||||
})
|
|
||||||
.catch(function onError(err) {
|
|
||||||
Notifications.error('Unable to retrieve host information', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -1,16 +0,0 @@
|
||||||
<rd-header>
|
|
||||||
<rd-header-title title-text="Host job execution"></rd-header-title>
|
|
||||||
<rd-header-content>
|
|
||||||
Host > <a ui-sref="docker.host">{{ $ctrl.host.Name }}</a> > execute job
|
|
||||||
</rd-header-content>
|
|
||||||
</rd-header>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body>
|
|
||||||
<execute-job-form></execute-job-form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,4 +0,0 @@
|
||||||
angular.module('portainer.docker').component('hostJobView', {
|
|
||||||
templateUrl: './host-job.html',
|
|
||||||
controller: 'HostJobController',
|
|
||||||
});
|
|
|
@ -8,8 +8,5 @@
|
||||||
refresh-url="docker.host"
|
refresh-url="docker.host"
|
||||||
browse-url="docker.host.browser"
|
browse-url="docker.host.browser"
|
||||||
offline-mode="$ctrl.state.offlineMode"
|
offline-mode="$ctrl.state.offlineMode"
|
||||||
is-job-enabled="$ctrl.state.isAdmin && !$ctrl.state.offlineMode"
|
|
||||||
host-features-enabled="$ctrl.state.enableHostManagementFeatures"
|
host-features-enabled="$ctrl.state.enableHostManagementFeatures"
|
||||||
job-url="docker.host.job"
|
|
||||||
jobs="$ctrl.jobs"
|
|
||||||
></host-overview>
|
></host-overview>
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
angular.module('portainer.docker').controller('NodeJobController', [
|
|
||||||
'$stateParams',
|
|
||||||
'NodeService',
|
|
||||||
'HttpRequestHelper',
|
|
||||||
'Notifications',
|
|
||||||
function NodeJobController($stateParams, NodeService, HttpRequestHelper, Notifications) {
|
|
||||||
var ctrl = this;
|
|
||||||
ctrl.$onInit = $onInit;
|
|
||||||
|
|
||||||
function $onInit() {
|
|
||||||
ctrl.nodeId = $stateParams.id;
|
|
||||||
|
|
||||||
NodeService.node(ctrl.nodeId)
|
|
||||||
.then(function onNodeLoaded(node) {
|
|
||||||
HttpRequestHelper.setPortainerAgentTargetHeader(node.Hostname);
|
|
||||||
ctrl.node = node;
|
|
||||||
})
|
|
||||||
.catch(function onError(err) {
|
|
||||||
Notifications.error('Unable to retrieve host information', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -1,16 +0,0 @@
|
||||||
<rd-header>
|
|
||||||
<rd-header-title title-text="Host job execution"></rd-header-title>
|
|
||||||
<rd-header-content>
|
|
||||||
<a ui-sref="docker.swarm">Swarm</a> > <a ui-sref="docker.nodes.node({ id: $ctrl.nodeId })">{{ $ctrl.node.Hostname }}</a> > execute job
|
|
||||||
</rd-header-content>
|
|
||||||
</rd-header>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body>
|
|
||||||
<execute-job-form node-name="$ctrl.node.Hostname"></execute-job-form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,4 +0,0 @@
|
||||||
angular.module('portainer.docker').component('nodeJobView', {
|
|
||||||
templateUrl: './node-job.html',
|
|
||||||
controller: 'NodeJobController',
|
|
||||||
});
|
|
|
@ -71,6 +71,39 @@ angular.module('portainer.edge', []).config(function config($stateRegistryProvid
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const edgeJobs = {
|
||||||
|
name: 'edge.jobs',
|
||||||
|
url: '/jobs',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'edgeJobsView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const edgeJob = {
|
||||||
|
name: 'edge.jobs.job',
|
||||||
|
url: '/:id',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'edgeJobView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
tab: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const edgeJobCreation = {
|
||||||
|
name: 'edge.jobs.new',
|
||||||
|
url: '/new',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'createEdgeJobView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
$stateRegistryProvider.register(edge);
|
$stateRegistryProvider.register(edge);
|
||||||
|
|
||||||
$stateRegistryProvider.register(groups);
|
$stateRegistryProvider.register(groups);
|
||||||
|
@ -80,4 +113,8 @@ angular.module('portainer.edge', []).config(function config($stateRegistryProvid
|
||||||
$stateRegistryProvider.register(stacks);
|
$stateRegistryProvider.register(stacks);
|
||||||
$stateRegistryProvider.register(stacksNew);
|
$stateRegistryProvider.register(stacksNew);
|
||||||
$stateRegistryProvider.register(stacksEdit);
|
$stateRegistryProvider.register(stacksEdit);
|
||||||
|
|
||||||
|
$stateRegistryProvider.register(edgeJobs);
|
||||||
|
$stateRegistryProvider.register(edgeJob);
|
||||||
|
$stateRegistryProvider.register(edgeJobCreation);
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,256 @@
|
||||||
|
<form class="form-horizontal" name="edgeJobForm">
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Edge job configuration
|
||||||
|
</div>
|
||||||
|
<!-- name-input -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edgejob_name" class="col-sm-1 control-label text-left">Name</label>
|
||||||
|
<div class="col-sm-11">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
ng-model="$ctrl.model.Name"
|
||||||
|
ng-pattern="/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/"
|
||||||
|
id="edgejob_name"
|
||||||
|
name="edgejob_name"
|
||||||
|
placeholder="backup-app-prod"
|
||||||
|
required
|
||||||
|
auto-focus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" ng-show="edgeJobForm.edgejob_name.$invalid">
|
||||||
|
<div class="col-sm-12 small text-warning">
|
||||||
|
<div ng-messages="edgeJobForm.edgejob_name.$error">
|
||||||
|
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||||
|
<p ng-message="pattern"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Allowed characters are: [a-zA-Z0-9_.-]</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !name-input -->
|
||||||
|
<!-- cron-input -->
|
||||||
|
<!-- edge-job-method-select -->
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Edge job configuration
|
||||||
|
</div>
|
||||||
|
<div class="form-group"></div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<div class="boxselector_wrapper">
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="config_basic" ng-model="$ctrl.formValues.cronMethod" value="basic" />
|
||||||
|
<label for="config_basic">
|
||||||
|
<div class="boxselector_header">
|
||||||
|
<i class="fa fa-calendar-alt" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
Basic configuration
|
||||||
|
</div>
|
||||||
|
<p>Select date from calendar</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="config_advanced" ng-model="$ctrl.formValues.cronMethod" value="advanced" />
|
||||||
|
<label for="config_advanced">
|
||||||
|
<div class="boxselector_header">
|
||||||
|
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
Advanced configuration
|
||||||
|
</div>
|
||||||
|
<p>Write your own cron rule</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !edge-job-method-select -->
|
||||||
|
<!-- basic-edge-job -->
|
||||||
|
<div ng-if="$ctrl.formValues.cronMethod === 'basic'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="recurring" class="col-sm-2 control-label text-left">Recurring Edge job</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" name="recurring" ng-model="$ctrl.model.Recurring" /><i></i> </label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- not-recurring -->
|
||||||
|
<div ng-if="!$ctrl.model.Recurring">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edgejob_cron" class="col-sm-2 control-label text-left">Schedule date</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input class="form-control" moment-picker ng-model="$ctrl.formValues.datetime" format="YYYY-MM-DD HH:mm" />
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12 small text-muted" style="margin-top: 10px;">
|
||||||
|
Time should be set according to the chosen endpoints' timezone.
|
||||||
|
</div>
|
||||||
|
<div ng-show="edgeJobForm.datepicker.$invalid">
|
||||||
|
<div class="col-sm-12 small text-warning">
|
||||||
|
<div ng-messages="edgeJobForm.datepicker.$error">
|
||||||
|
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !not-recurring -->
|
||||||
|
<!-- recurring -->
|
||||||
|
<div ng-if="$ctrl.model.Recurring">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edgejob_value" class="col-sm-2 control-label text-left">Edge job time</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select
|
||||||
|
id="edgejob_value"
|
||||||
|
name="edgejob_value"
|
||||||
|
class="form-control"
|
||||||
|
ng-model="$ctrl.formValues.scheduleValue"
|
||||||
|
ng-options="value.displayed for value in $ctrl.scheduleValues"
|
||||||
|
required
|
||||||
|
></select>
|
||||||
|
</div>
|
||||||
|
<div ng-show="edgeJobForm.edgejob_value.$invalid">
|
||||||
|
<div class="col-sm-12 small text-warning">
|
||||||
|
<div ng-messages="edgeJobForm.edgejob_value.$error">
|
||||||
|
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !recurring -->
|
||||||
|
</div>
|
||||||
|
<!-- !basic-edge-job -->
|
||||||
|
<!-- advanced-schedule -->
|
||||||
|
<div ng-if="$ctrl.formValues.cronMethod === 'advanced'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edgejob_cron" class="col-sm-2 control-label text-left">Cron rule</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
ng-model="$ctrl.model.CronExpression"
|
||||||
|
id="edgejob_cron"
|
||||||
|
name="edgejob_cron"
|
||||||
|
placeholder="0 2 * * *"
|
||||||
|
required
|
||||||
|
ng-pattern="$ctrl.cronRegex"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12 small text-muted" style="margin-top: 10px;">
|
||||||
|
Time should be set according to the chosen endpoints' timezone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" ng-show="edgeJobForm.edgejob_cron.$invalid && edgeJobForm.edgejob_cron.$dirty">
|
||||||
|
<div class="col-sm-12 small text-warning">
|
||||||
|
<div ng-messages="edgeJobForm.edgejob_cron.$error">
|
||||||
|
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||||
|
<p ng-message="pattern"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field format is invalid.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !advanced-schedule -->
|
||||||
|
|
||||||
|
<!-- execution-method -->
|
||||||
|
<div ng-if="!$ctrl.model.Id">
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Job content
|
||||||
|
</div>
|
||||||
|
<div class="form-group"></div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<div class="boxselector_wrapper">
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="method_editor" ng-model="$ctrl.formValues.method" value="editor" />
|
||||||
|
<label for="method_editor">
|
||||||
|
<div class="boxselector_header">
|
||||||
|
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
Web editor
|
||||||
|
</div>
|
||||||
|
<p>Use our Web editor</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="method_upload" ng-model="$ctrl.formValues.method" value="upload" />
|
||||||
|
<label for="method_upload">
|
||||||
|
<div class="boxselector_header">
|
||||||
|
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
Upload
|
||||||
|
</div>
|
||||||
|
<p>Upload from your computer</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !execution-method -->
|
||||||
|
<!-- web-editor -->
|
||||||
|
<div ng-show="$ctrl.formValues.method === 'editor'">
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Web editor
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<code-editor
|
||||||
|
identifier="execute-edge-job-editor"
|
||||||
|
placeholder="# Define or paste the content of your script file here"
|
||||||
|
on-change="($ctrl.editorUpdate)"
|
||||||
|
value="$ctrl.model.FileContent"
|
||||||
|
></code-editor>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !web-editor -->
|
||||||
|
<!-- upload -->
|
||||||
|
<div ng-show="$ctrl.formValues.method === 'upload'">
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Upload
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<span class="col-sm-12 text-muted small">
|
||||||
|
You can upload a script file from your computer.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<button class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.model.File">Select file</button>
|
||||||
|
<span style="margin-left: 5px;">
|
||||||
|
{{ $ctrl.model.File.name }}
|
||||||
|
<i class="fa fa-times red-icon" ng-if="!$ctrl.model.File" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !upload -->
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Target endpoints
|
||||||
|
</div>
|
||||||
|
<!-- node-selection -->
|
||||||
|
<associated-endpoints-selector
|
||||||
|
endpoint-ids="$ctrl.model.Endpoints"
|
||||||
|
tags="$ctrl.tags"
|
||||||
|
groups="$ctrl.groups"
|
||||||
|
has-backend-pagination="true"
|
||||||
|
on-associate="($ctrl.associateEndpoint)"
|
||||||
|
on-dissociate="($ctrl.dissociateEndpoint)"
|
||||||
|
></associated-endpoints-selector>
|
||||||
|
<!-- !node-selection -->
|
||||||
|
<!-- actions -->
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Actions
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
ng-disabled="$ctrl.actionInProgress || !edgeJobForm.$valid
|
||||||
|
|| $ctrl.model.Endpoints.length === 0
|
||||||
|
|| ($ctrl.formValues.method === 'upload' && !$ctrl.model.File)
|
||||||
|
|| ($ctrl.formValues.method === 'editor' && !$ctrl.model.FileContent)
|
||||||
|
"
|
||||||
|
ng-click="$ctrl.action()"
|
||||||
|
button-spinner="$ctrl.actionInProgress"
|
||||||
|
>
|
||||||
|
<span ng-hide="$ctrl.actionInProgress">{{ $ctrl.formActionLabel }}</span>
|
||||||
|
<span ng-show="$ctrl.actionInProgress">In progress...</span>
|
||||||
|
</button>
|
||||||
|
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
|
||||||
|
{{ $ctrl.state.formValidationError }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !actions -->
|
||||||
|
</form>
|
|
@ -0,0 +1,16 @@
|
||||||
|
import EdgeJobFormController from './edgeJobFormController';
|
||||||
|
|
||||||
|
angular.module('portainer.edge').component('edgeJobForm', {
|
||||||
|
templateUrl: './edgeJobForm.html',
|
||||||
|
controller: EdgeJobFormController,
|
||||||
|
bindings: {
|
||||||
|
model: '=',
|
||||||
|
groups: '<',
|
||||||
|
tags: '<',
|
||||||
|
addLabelAction: '<',
|
||||||
|
removeLabelAction: '<',
|
||||||
|
formAction: '<',
|
||||||
|
formActionLabel: '@',
|
||||||
|
actionInProgress: '<',
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,107 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
import _ from 'lodash-es';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
class EdgeJobFormController {
|
||||||
|
/* @ngInject */
|
||||||
|
constructor() {
|
||||||
|
this.state = {
|
||||||
|
formValidationError: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.scheduleValues = [
|
||||||
|
{
|
||||||
|
displayed: 'Every hour',
|
||||||
|
cron: '0 * * * *',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayed: 'Every 2 hours',
|
||||||
|
cron: '0 */2 * * *',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayed: 'Every day',
|
||||||
|
cron: '0 0 * * *',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
this.formValues = {
|
||||||
|
datetime: moment(),
|
||||||
|
scheduleValue: this.scheduleValues[0],
|
||||||
|
cronMethod: 'basic',
|
||||||
|
method: 'editor',
|
||||||
|
};
|
||||||
|
|
||||||
|
// see https://regexr.com/573i2
|
||||||
|
this.cronRegex = /(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ){4,6}((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*))/;
|
||||||
|
|
||||||
|
this.onChangeModel(this.model);
|
||||||
|
|
||||||
|
this.action = this.action.bind(this);
|
||||||
|
this.editorUpdate = this.editorUpdate.bind(this);
|
||||||
|
this.associateEndpoint = this.associateEndpoint.bind(this);
|
||||||
|
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeModel(model) {
|
||||||
|
const defaultTime = moment().add('hours', 1);
|
||||||
|
this.formValues = {
|
||||||
|
datetime: model.CronExpression ? cronToDatetime(model.CronExpression, defaultTime) : defaultTime,
|
||||||
|
scheduleValue: this.formValues.scheduleValue,
|
||||||
|
cronMethod: model.Recurring ? 'advanced' : 'basic',
|
||||||
|
method: this.formValues.method,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
action() {
|
||||||
|
this.state.formValidationError = '';
|
||||||
|
|
||||||
|
if (this.formValues.method === 'editor' && this.model.FileContent === '') {
|
||||||
|
this.state.formValidationError = 'Script file content must not be empty';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.formValues.cronMethod === 'basic') {
|
||||||
|
if (!this.model.Recurring) {
|
||||||
|
this.model.CronExpression = datetimeToCron(this.formValues.datetime);
|
||||||
|
} else {
|
||||||
|
this.model.CronExpression = this.formValues.scheduleValue.cron;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.model.Recurring = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.formAction(this.formValues.method);
|
||||||
|
}
|
||||||
|
|
||||||
|
editorUpdate(cm) {
|
||||||
|
this.model.FileContent = cm.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
associateEndpoint(endpoint) {
|
||||||
|
if (!_.includes(this.model.Endpoints, endpoint.Id)) {
|
||||||
|
this.model.Endpoints = [...this.model.Endpoints, endpoint.Id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dissociateEndpoint(endpoint) {
|
||||||
|
this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cronToDatetime(cron, defaultTime = moment()) {
|
||||||
|
var strings = cron.split(' ');
|
||||||
|
if (strings.length > 4) {
|
||||||
|
strings = strings.slice(0, 4);
|
||||||
|
} else {
|
||||||
|
return defaultTime;
|
||||||
|
}
|
||||||
|
return moment(cron, 'm H D M');
|
||||||
|
}
|
||||||
|
|
||||||
|
function datetimeToCron(datetime) {
|
||||||
|
var date = moment(datetime);
|
||||||
|
return [date.minutes(), date.hours(), date.date(), date.month() + 1, '*'].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
angular.module('portainer.edge').controller('EdgeJobFormController', EdgeJobFormController);
|
||||||
|
export default EdgeJobFormController;
|
|
@ -0,0 +1,3 @@
|
||||||
|
.edge-job-results-datatable thead th {
|
||||||
|
width: 50%;
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
<div class="datatable edge-job-results-datatable">
|
||||||
|
<rd-widget>
|
||||||
|
<rd-widget-body classes="no-padding">
|
||||||
|
<div class="toolBar">
|
||||||
|
<div class="toolBarTitle">
|
||||||
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
{{ $ctrl.titleText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="searchBar">
|
||||||
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }" />
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-filters nowrap-cells">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<a ng-click="$ctrl.changeOrderBy('Endpoint')">
|
||||||
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Endpoint' && !$ctrl.state.reverseOrder"></i>
|
||||||
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Endpoint' && $ctrl.state.reverseOrder"></i>
|
||||||
|
Endpoint
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
{{ item.Endpoint.Name }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button ng-if="item.LogsStatus === 0 || item.LogsStatus === 1" class="btn btn-sm btn-primary" ng-click="$ctrl.collectLogs(item.EndpointId)">
|
||||||
|
Retrieve logs
|
||||||
|
</button>
|
||||||
|
<button ng-if="item.LogsStatus === 3" class="btn btn-sm btn-primary" ng-click="$ctrl.onDownloadLogsClick(item.EndpointId)">
|
||||||
|
Download logs
|
||||||
|
</button>
|
||||||
|
<button ng-if="item.LogsStatus === 3" class="btn btn-sm btn-primary" ng-click="$ctrl.onClearLogsClick(item.EndpointId)">
|
||||||
|
Clear logs
|
||||||
|
</button>
|
||||||
|
<span ng-if="item.LogsStatus === 2">
|
||||||
|
Logs marked for collection, please wait until the logs are available.
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr ng-if="!$ctrl.dataset">
|
||||||
|
<td colspan="9" class="text-center text-muted">Loading...</td>
|
||||||
|
</tr>
|
||||||
|
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
||||||
|
<td colspan="9" class="text-center text-muted">No result available.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="footer" ng-if="$ctrl.dataset">
|
||||||
|
<div class="paginationControls">
|
||||||
|
<form class="form-inline">
|
||||||
|
<span class="limitSelector">
|
||||||
|
<span style="margin-right: 5px;">
|
||||||
|
Items per page
|
||||||
|
</span>
|
||||||
|
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
|
||||||
|
<option value="0">All</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
<dir-pagination-controls max-size="5"></dir-pagination-controls>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</rd-widget-body>
|
||||||
|
</rd-widget>
|
||||||
|
</div>
|
|
@ -0,0 +1,20 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
import EdgeJobResultsDatatableController from './edgeJobResultsDatatableController';
|
||||||
|
import './edgeJobResultsDatatable.css';
|
||||||
|
|
||||||
|
angular.module('portainer.edge').component('edgeJobResultsDatatable', {
|
||||||
|
templateUrl: './edgeJobResultsDatatable.html',
|
||||||
|
controller: EdgeJobResultsDatatableController,
|
||||||
|
bindings: {
|
||||||
|
titleText: '@',
|
||||||
|
titleIcon: '@',
|
||||||
|
dataset: '<',
|
||||||
|
tableKey: '@',
|
||||||
|
orderBy: '@',
|
||||||
|
reverseOrder: '<',
|
||||||
|
onDownloadLogsClick: '<',
|
||||||
|
onCollectLogsClick: '<',
|
||||||
|
onClearLogsClick: '<',
|
||||||
|
refreshCallback: '<',
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,29 @@
|
||||||
|
import _ from 'lodash-es';
|
||||||
|
|
||||||
|
export default class EdgeJobResultsDatatableController {
|
||||||
|
/* @ngInject */
|
||||||
|
constructor($controller, $scope, $state) {
|
||||||
|
this.$state = $state;
|
||||||
|
angular.extend(this, $controller('GenericDatatableController', { $scope }));
|
||||||
|
}
|
||||||
|
|
||||||
|
collectLogs(...args) {
|
||||||
|
this.settings.repeater.autoRefresh = true;
|
||||||
|
this.settings.repeater.refreshRate = '5';
|
||||||
|
this.onSettingsRepeaterChange();
|
||||||
|
this.onCollectLogsClick(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
$onChanges({ dataset }) {
|
||||||
|
if (dataset && dataset.currentValue) {
|
||||||
|
this.onDatasetChange(dataset.currentValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDatasetChange(dataset) {
|
||||||
|
const anyCollecting = _.some(dataset, (item) => item.LogsStatus === 2);
|
||||||
|
this.settings.repeater.autoRefresh = anyCollecting;
|
||||||
|
this.settings.repeater.refreshRate = '5';
|
||||||
|
this.onSettingsRepeaterChange();
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
|
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
|
||||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.schedules.new"> <i class="fa fa-plus space-right" aria-hidden="true"></i>Add schedule </button>
|
<button type="button" class="btn btn-sm btn-primary" ui-sref="edge.jobs.new"> <i class="fa fa-plus space-right" aria-hidden="true"></i>Add Edge job </button>
|
||||||
</div>
|
</div>
|
||||||
<div class="searchBar">
|
<div class="searchBar">
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
|
@ -52,11 +52,10 @@
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<span class="md-checkbox">
|
<span class="md-checkbox">
|
||||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" ng-disabled="!$ctrl.allowSelection(item)" />
|
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" />
|
||||||
<label for="select_{{ $index }}"></label>
|
<label for="select_{{ $index }}"></label>
|
||||||
</span>
|
</span>
|
||||||
<span ng-if="item.JobType !== 1">{{ item.Name }}</span>
|
<a ui-sref="edge.jobs.job({id: item.Id})">{{ item.Name }}</a>
|
||||||
<a ng-if="item.JobType === 1" ui-sref="portainer.schedules.schedule({id: item.Id})">{{ item.Name }}</a>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ item.CronExpression }}
|
{{ item.CronExpression }}
|
||||||
|
@ -67,7 +66,7 @@
|
||||||
<td colspan="3" class="text-center text-muted">Loading...</td>
|
<td colspan="3" class="text-center text-muted">Loading...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
||||||
<td colspan="3" class="text-center text-muted">No schedule available.</td>
|
<td colspan="3" class="text-center text-muted">No Edge job available.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
|
@ -1,5 +1,7 @@
|
||||||
angular.module('portainer.docker').component('scheduleTasksDatatable', {
|
import angular from 'angular';
|
||||||
templateUrl: './scheduleTasksDatatable.html',
|
|
||||||
|
angular.module('portainer.edge').component('edgeJobsDatatable', {
|
||||||
|
templateUrl: './edgeJobsDatatable.html',
|
||||||
controller: 'GenericDatatableController',
|
controller: 'GenericDatatableController',
|
||||||
bindings: {
|
bindings: {
|
||||||
titleText: '@',
|
titleText: '@',
|
||||||
|
@ -8,7 +10,6 @@ angular.module('portainer.docker').component('scheduleTasksDatatable', {
|
||||||
tableKey: '@',
|
tableKey: '@',
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
goToContainerLogs: '<',
|
removeAction: '<',
|
||||||
getEdgeTaskLogs: '<',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
|
@ -54,55 +54,15 @@
|
||||||
Associated endpoints
|
Associated endpoints
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12 small text-muted">
|
<associated-endpoints-selector
|
||||||
You can select which endpoint should be part of this group by moving them to the associated endpoints table. Simply click on any endpoint entry to move it from one table
|
endpoint-ids="$ctrl.model.Endpoints"
|
||||||
to the other.
|
tags="$ctrl.tags"
|
||||||
</div>
|
groups="$ctrl.groups"
|
||||||
<div class="col-sm-12" style="margin-top: 20px;">
|
has-backend-pagination="true"
|
||||||
<!-- available-endpoints -->
|
|
||||||
<div class="col-sm-6">
|
on-associate="$ctrl.associateEndpoint"
|
||||||
<div class="text-center small text-muted">Available endpoints</div>
|
on-dissociate="$ctrl.dissociateEndpoint"
|
||||||
<div style="margin-top: 10px;">
|
></associated-endpoints-selector>
|
||||||
<group-association-table
|
|
||||||
loaded="$ctrl.loaded"
|
|
||||||
page-type="$ctrl.pageType"
|
|
||||||
table-type="available"
|
|
||||||
retrieve-page="$ctrl.getPaginatedEndpoints"
|
|
||||||
dataset="$ctrl.endpoints.available"
|
|
||||||
entry-click="$ctrl.associateEndpoint"
|
|
||||||
pagination-state="$ctrl.state.available"
|
|
||||||
empty-dataset-message="No endpoint available"
|
|
||||||
tags="$ctrl.tags"
|
|
||||||
show-tags="true"
|
|
||||||
groups="$ctrl.groups"
|
|
||||||
show-groups="true"
|
|
||||||
has-backend-pagination="true"
|
|
||||||
></group-association-table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !available-endpoints -->
|
|
||||||
<!-- associated-endpoints -->
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<div class="text-center small text-muted">Associated endpoints</div>
|
|
||||||
<div style="margin-top: 10px;">
|
|
||||||
<group-association-table
|
|
||||||
loaded="$ctrl.loaded"
|
|
||||||
page-type="$ctrl.pageType"
|
|
||||||
table-type="associated"
|
|
||||||
retrieve-page="$ctrl.getPaginatedEndpoints"
|
|
||||||
dataset="$ctrl.endpoints.associated"
|
|
||||||
entry-click="$ctrl.dissociateEndpoint"
|
|
||||||
pagination-state="$ctrl.state.associated"
|
|
||||||
empty-dataset-message="No associated endpoint"
|
|
||||||
tags="$ctrl.tags"
|
|
||||||
show-tags="true"
|
|
||||||
groups="$ctrl.groups"
|
|
||||||
show-groups="true"
|
|
||||||
has-backend-pagination="true"
|
|
||||||
></group-association-table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !associated-endpoints -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -155,9 +115,9 @@
|
||||||
loaded="$ctrl.loaded"
|
loaded="$ctrl.loaded"
|
||||||
page-type="$ctrl.pageType"
|
page-type="$ctrl.pageType"
|
||||||
table-type="associated"
|
table-type="associated"
|
||||||
retrieve-page="$ctrl.getPaginatedEndpoints"
|
retrieve-page="$ctrl.getDynamicEndpoints"
|
||||||
dataset="$ctrl.endpoints.associated"
|
dataset="$ctrl.endpoints.value"
|
||||||
pagination-state="$ctrl.state.associated"
|
pagination-state="$ctrl.endpoints.state"
|
||||||
empty-dataset-message="No associated endpoint"
|
empty-dataset-message="No associated endpoint"
|
||||||
tags="$ctrl.tags"
|
tags="$ctrl.tags"
|
||||||
show-tags="true"
|
show-tags="true"
|
||||||
|
|
|
@ -7,35 +7,27 @@ class EdgeGroupFormController {
|
||||||
this.EndpointService = EndpointService;
|
this.EndpointService = EndpointService;
|
||||||
this.$async = $async;
|
this.$async = $async;
|
||||||
|
|
||||||
this.state = {
|
|
||||||
available: {
|
|
||||||
limit: '10',
|
|
||||||
filter: '',
|
|
||||||
pageNumber: 1,
|
|
||||||
totalCount: 0,
|
|
||||||
},
|
|
||||||
associated: {
|
|
||||||
limit: '10',
|
|
||||||
filter: '',
|
|
||||||
pageNumber: 1,
|
|
||||||
totalCount: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.endpoints = {
|
this.endpoints = {
|
||||||
associated: [],
|
state: {
|
||||||
available: null,
|
limit: '10',
|
||||||
|
filter: '',
|
||||||
|
pageNumber: 1,
|
||||||
|
totalCount: 0,
|
||||||
|
},
|
||||||
|
value: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.associateEndpoint = this.associateEndpoint.bind(this);
|
this.associateEndpoint = this.associateEndpoint.bind(this);
|
||||||
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
|
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
|
||||||
this.getPaginatedEndpointsAsync = this.getPaginatedEndpointsAsync.bind(this);
|
this.getDynamicEndpointsAsync = this.getDynamicEndpointsAsync.bind(this);
|
||||||
this.getPaginatedEndpoints = this.getPaginatedEndpoints.bind(this);
|
this.getDynamicEndpoints = this.getDynamicEndpoints.bind(this);
|
||||||
|
|
||||||
$scope.$watch(
|
$scope.$watch(
|
||||||
() => this.model,
|
() => this.model,
|
||||||
() => {
|
() => {
|
||||||
this.getPaginatedEndpoints(this.pageType, 'associated');
|
if (this.model.Dynamic) {
|
||||||
|
this.getDynamicEndpoints();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
@ -43,50 +35,28 @@ class EdgeGroupFormController {
|
||||||
|
|
||||||
associateEndpoint(endpoint) {
|
associateEndpoint(endpoint) {
|
||||||
if (!_.includes(this.model.Endpoints, endpoint.Id)) {
|
if (!_.includes(this.model.Endpoints, endpoint.Id)) {
|
||||||
this.endpoints.associated.push(endpoint);
|
this.model.Endpoints = [...this.model.Endpoints, endpoint.Id];
|
||||||
this.model.Endpoints.push(endpoint.Id);
|
|
||||||
_.remove(this.endpoints.available, { Id: endpoint.Id });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dissociateEndpoint(endpoint) {
|
dissociateEndpoint(endpoint) {
|
||||||
_.remove(this.endpoints.associated, { Id: endpoint.Id });
|
this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id);
|
||||||
_.remove(this.model.Endpoints, (id) => id === endpoint.Id);
|
|
||||||
this.endpoints.available.push(endpoint);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPaginatedEndpoints(pageType, tableType) {
|
getDynamicEndpoints() {
|
||||||
return this.$async(this.getPaginatedEndpointsAsync, pageType, tableType);
|
return this.$async(this.getDynamicEndpointsAsync);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPaginatedEndpointsAsync(pageType, tableType) {
|
async getDynamicEndpointsAsync() {
|
||||||
const { pageNumber, limit, search } = this.state[tableType];
|
const { pageNumber, limit, search } = this.endpoints.state;
|
||||||
const start = (pageNumber - 1) * limit + 1;
|
const start = (pageNumber - 1) * limit + 1;
|
||||||
const query = { search, type: 4 };
|
const query = { search, type: 4, tagIds: this.model.TagIds, tagsPartialMatch: this.model.PartialMatch };
|
||||||
if (tableType === 'associated') {
|
|
||||||
if (this.model.Dynamic) {
|
const response = await this.EndpointService.endpoints(start, limit, query);
|
||||||
query.tagIds = this.model.TagIds;
|
|
||||||
query.tagsPartialMatch = this.model.PartialMatch;
|
|
||||||
} else {
|
|
||||||
query.endpointIds = this.model.Endpoints;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const response = await this.fetchEndpoints(start, limit, query);
|
|
||||||
const totalCount = parseInt(response.totalCount, 10);
|
const totalCount = parseInt(response.totalCount, 10);
|
||||||
this.endpoints[tableType] = response.value;
|
this.endpoints.value = response.value;
|
||||||
this.state[tableType].totalCount = totalCount;
|
this.endpoints.state.totalCount = totalCount;
|
||||||
|
|
||||||
if (tableType === 'available') {
|
|
||||||
this.noEndpoints = totalCount === 0;
|
|
||||||
this.endpoints[tableType] = _.filter(response.value, (endpoint) => !_.includes(this.model.Endpoints, endpoint.Id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchEndpoints(start, limit, query) {
|
|
||||||
if (query.tagIds && !query.tagIds.length) {
|
|
||||||
return { value: [], totalCount: 0 };
|
|
||||||
}
|
|
||||||
return this.EndpointService.endpoints(start, limit, query);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
angular.module('portainer.edge').factory('EdgeJobResults', EdgeJobResultsFactory);
|
||||||
|
|
||||||
|
function EdgeJobResultsFactory($resource, API_ENDPOINT_EDGE_JOBS) {
|
||||||
|
return $resource(
|
||||||
|
API_ENDPOINT_EDGE_JOBS + '/:id/tasks/:taskId/:action',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
query: { method: 'GET', isArray: true, params: { id: '@id' } },
|
||||||
|
logFile: { method: 'GET', params: { id: '@id', taskId: '@taskId', action: 'logs' } },
|
||||||
|
clearLogs: { method: 'DELETE', params: { id: '@id', taskId: '@taskId', action: 'logs' } },
|
||||||
|
collectLogs: { method: 'POST', params: { id: '@id', taskId: '@taskId', action: 'logs' } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
angular.module('portainer.edge').factory('EdgeJobs', EdgeJobsFactory);
|
||||||
|
|
||||||
|
function EdgeJobsFactory($resource, API_ENDPOINT_EDGE_JOBS) {
|
||||||
|
return $resource(
|
||||||
|
API_ENDPOINT_EDGE_JOBS + '/:id/:action',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
create: { method: 'POST' },
|
||||||
|
query: { method: 'GET', isArray: true },
|
||||||
|
get: { method: 'GET', params: { id: '@id' } },
|
||||||
|
update: { method: 'PUT', params: { id: '@id' } },
|
||||||
|
remove: { method: 'DELETE', params: { id: '@id' } },
|
||||||
|
file: { method: 'GET', params: { id: '@id', action: 'file' } },
|
||||||
|
tasks: { method: 'GET', isArray: true, params: { id: '@id', action: 'tasks' } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import { ScheduleCreateRequest, ScheduleUpdateRequest } from 'Portainer/models/schedule';
|
||||||
|
|
||||||
|
function EdgeJobService(EdgeJobs, EdgeJobResults, FileUploadService) {
|
||||||
|
var service = {};
|
||||||
|
|
||||||
|
service.edgeJob = edgeJob;
|
||||||
|
async function edgeJob(edgeJobId) {
|
||||||
|
try {
|
||||||
|
return await EdgeJobs.get({ id: edgeJobId }).$promise;
|
||||||
|
} catch (err) {
|
||||||
|
throw { msg: 'Unable to retrieve edgeJob', err: err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service.edgeJobs = edgeJobs;
|
||||||
|
async function edgeJobs() {
|
||||||
|
try {
|
||||||
|
return await EdgeJobs.query().$promise;
|
||||||
|
} catch (err) {
|
||||||
|
throw { msg: 'Unable to retrieve edgeJobs', err: err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service.jobResults = jobResults;
|
||||||
|
async function jobResults(edgeJobId) {
|
||||||
|
try {
|
||||||
|
return await EdgeJobResults.query({ id: edgeJobId }).$promise;
|
||||||
|
} catch (err) {
|
||||||
|
throw { msg: 'Unable to retrieve results associated to the edgeJob', err: err };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service.logFile = logFile;
|
||||||
|
function logFile(id, taskId) {
|
||||||
|
return EdgeJobResults.logFile({ id, taskId }).$promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
service.collectLogs = collectLogs;
|
||||||
|
function collectLogs(id, taskId) {
|
||||||
|
return EdgeJobResults.collectLogs({ id, taskId }).$promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
service.clearLogs = clearLogs;
|
||||||
|
function clearLogs(id, taskId) {
|
||||||
|
return EdgeJobResults.clearLogs({ id, taskId }).$promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
service.createEdgeJobFromFileContent = function (model) {
|
||||||
|
var payload = new ScheduleCreateRequest(model);
|
||||||
|
return EdgeJobs.create({ method: 'string' }, payload).$promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
service.createEdgeJobFromFileUpload = function (model) {
|
||||||
|
var payload = new ScheduleCreateRequest(model);
|
||||||
|
return FileUploadService.createSchedule(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
service.updateEdgeJob = function (model) {
|
||||||
|
var payload = new ScheduleUpdateRequest(model);
|
||||||
|
return EdgeJobs.update(payload).$promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
service.remove = function (edgeJobId) {
|
||||||
|
return EdgeJobs.remove({ id: edgeJobId }).$promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
service.getScriptFile = function (edgeJobId) {
|
||||||
|
return EdgeJobs.file({ id: edgeJobId }).$promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
angular.module('portainer.edge').factory('EdgeJobService', EdgeJobService);
|
|
@ -0,0 +1,21 @@
|
||||||
|
<rd-header>
|
||||||
|
<rd-header-title title-text="Create Edge job"></rd-header-title>
|
||||||
|
<rd-header-content> <a ui-sref="edge.jobs">Edge Jobs</a> > Create Edge job </rd-header-content>
|
||||||
|
</rd-header>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<rd-widget>
|
||||||
|
<rd-widget-body>
|
||||||
|
<edge-job-form
|
||||||
|
model="$ctrl.model"
|
||||||
|
groups="$ctrl.groups"
|
||||||
|
tags="$ctrl.tags"
|
||||||
|
form-action="$ctrl.create"
|
||||||
|
form-action-label="Create edge job"
|
||||||
|
action-in-progress="$ctrl.state.actionInProgress"
|
||||||
|
></edge-job-form>
|
||||||
|
</rd-widget-body>
|
||||||
|
</rd-widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,7 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
import CreateEdgeJobViewController from './createEdgeJobViewController';
|
||||||
|
|
||||||
|
angular.module('portainer.edge').component('createEdgeJobView', {
|
||||||
|
templateUrl: './createEdgeJobView.html',
|
||||||
|
controller: CreateEdgeJobViewController,
|
||||||
|
});
|
|
@ -0,0 +1,68 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
class CreateEdgeJobController {
|
||||||
|
constructor($async, $q, $state, EdgeJobService, GroupService, Notifications, TagService) {
|
||||||
|
this.state = {
|
||||||
|
actionInProgress: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.$async = $async;
|
||||||
|
this.$q = $q;
|
||||||
|
this.$state = $state;
|
||||||
|
this.Notifications = Notifications;
|
||||||
|
this.GroupService = GroupService;
|
||||||
|
this.EdgeJobService = EdgeJobService;
|
||||||
|
this.TagService = TagService;
|
||||||
|
|
||||||
|
this.create = this.create.bind(this);
|
||||||
|
this.createEdgeJob = this.createEdgeJob.bind(this);
|
||||||
|
this.createAsync = this.createAsync.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(method) {
|
||||||
|
return this.$async(this.createAsync, method);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAsync(method) {
|
||||||
|
this.state.actionInProgress = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.createEdgeJob(method, this.model);
|
||||||
|
this.Notifications.success('Edge job successfully created');
|
||||||
|
this.$state.go('edge.jobs', {}, { reload: true });
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to create Edge job');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.actionInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
createEdgeJob(method, model) {
|
||||||
|
if (method === 'editor') {
|
||||||
|
return this.EdgeJobService.createEdgeJobFromFileContent(model);
|
||||||
|
}
|
||||||
|
return this.EdgeJobService.createEdgeJobFromFileUpload(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
async $onInit() {
|
||||||
|
this.model = {
|
||||||
|
Name: '',
|
||||||
|
Recurring: false,
|
||||||
|
CronExpression: '',
|
||||||
|
Endpoints: [],
|
||||||
|
FileContent: '',
|
||||||
|
File: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [groups, tags] = await Promise.all([this.GroupService.groups(), this.TagService.tags()]);
|
||||||
|
this.groups = groups;
|
||||||
|
this.tags = tags;
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to retrieve page data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
angular.module('portainer.edge').controller('CreateEdgeJobController', CreateEdgeJobController);
|
||||||
|
export default CreateEdgeJobController;
|
|
@ -0,0 +1,23 @@
|
||||||
|
<rd-header>
|
||||||
|
<rd-header-title title-text="Edge Jobs">
|
||||||
|
<a data-toggle="tooltip" title="Refresh" ui-sref="edge.jobs" ui-sref-opts="{reload: true}">
|
||||||
|
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</rd-header-title>
|
||||||
|
<rd-header-content>Edge Jobs</rd-header-content>
|
||||||
|
</rd-header>
|
||||||
|
|
||||||
|
<beta-panel></beta-panel>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<edge-jobs-datatable
|
||||||
|
title-text="Edge jobs"
|
||||||
|
title-icon="fa-clock"
|
||||||
|
dataset="$ctrl.edgeJobs"
|
||||||
|
table-key="edgeJobs"
|
||||||
|
order-by="Name"
|
||||||
|
remove-action="$ctrl.removeAction"
|
||||||
|
></edge-jobs-datatable>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,7 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
import EdgeJobsViewController from './edgeJobsViewController';
|
||||||
|
|
||||||
|
angular.module('portainer.edge').component('edgeJobsView', {
|
||||||
|
templateUrl: './edgeJobsView.html',
|
||||||
|
controller: EdgeJobsViewController,
|
||||||
|
});
|
|
@ -0,0 +1,56 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
import _ from 'lodash-es';
|
||||||
|
|
||||||
|
class EdgeJobsController {
|
||||||
|
constructor($async, $state, EdgeJobService, ModalService, Notifications) {
|
||||||
|
this.$async = $async;
|
||||||
|
this.$state = $state;
|
||||||
|
this.EdgeJobService = EdgeJobService;
|
||||||
|
this.ModalService = ModalService;
|
||||||
|
this.Notifications = Notifications;
|
||||||
|
|
||||||
|
this.removeAction = this.removeAction.bind(this);
|
||||||
|
this.deleteJobsAsync = this.deleteJobsAsync.bind(this);
|
||||||
|
this.deleteJobs = this.deleteJobs.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAction(selectedItems) {
|
||||||
|
this.ModalService.confirmDeletion('Do you want to remove the selected edge job(s) ?', (confirmed) => {
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.deleteJobs(selectedItems);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteJobs(edgeJobs) {
|
||||||
|
return this.$async(this.deleteJobsAsync, edgeJobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteJobsAsync(edgeJobs) {
|
||||||
|
for (let edgeJob of edgeJobs) {
|
||||||
|
try {
|
||||||
|
await this.EdgeJobService.remove(edgeJob.Id);
|
||||||
|
this.Notifications.success('Stack successfully removed', edgeJob.Name);
|
||||||
|
_.remove(this.edgeJobs, edgeJob);
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to remove Edge job ' + edgeJob.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$state.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async $onInit() {
|
||||||
|
try {
|
||||||
|
const edgeJobs = await this.EdgeJobService.edgeJobs();
|
||||||
|
this.edgeJobs = edgeJobs;
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to retrieve Edge jobs');
|
||||||
|
this.edgeJobs = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
angular.module('portainer.edge').controller('EdgeJobsController', EdgeJobsController);
|
||||||
|
export default EdgeJobsController;
|
|
@ -0,0 +1,52 @@
|
||||||
|
<rd-header>
|
||||||
|
<rd-header-title title-text="Edge job details">
|
||||||
|
<a data-toggle="tooltip" title-text="Refresh" ui-sref="edge.jobs.job({id: $ctrl.edgeJob.Id, tab: $ctrl.state.activeTab})" ui-sref-opts="{reload: true}">
|
||||||
|
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</rd-header-title>
|
||||||
|
<rd-header-content> <a ui-sref="edge.jobs">Edge jobs</a> > {{ ::$ctrl.edgeJob.Name }} </rd-header-content>
|
||||||
|
</rd-header>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<rd-widget>
|
||||||
|
<rd-widget-body>
|
||||||
|
<uib-tabset active="$ctrl.state.activeTab">
|
||||||
|
<uib-tab index="0" select="$ctrl.showEditor()">
|
||||||
|
<uib-tab-heading> <i class="fa fa-wrench" aria-hidden="true"></i> Configuration </uib-tab-heading>
|
||||||
|
|
||||||
|
<edge-job-form
|
||||||
|
ng-if="$ctrl.edgeJob && $ctrl.state.showEditorTab"
|
||||||
|
model="$ctrl.edgeJob"
|
||||||
|
endpoints="endpoints"
|
||||||
|
groups="$ctrl.groups"
|
||||||
|
tags="$ctrl.tags"
|
||||||
|
form-action="$ctrl.update"
|
||||||
|
form-action-label="Update Edge job"
|
||||||
|
action-in-progress="$ctrl.state.actionInProgress"
|
||||||
|
></edge-job-form>
|
||||||
|
</uib-tab>
|
||||||
|
|
||||||
|
<uib-tab index="1">
|
||||||
|
<uib-tab-heading> <i class="fa fa-tasks" aria-hidden="true"></i> Results </uib-tab-heading>
|
||||||
|
|
||||||
|
<edge-job-results-datatable
|
||||||
|
style="display: block; margin-top: 10px;"
|
||||||
|
ng-if="$ctrl.results"
|
||||||
|
title-text="Results"
|
||||||
|
title-icon="fa-tasks"
|
||||||
|
dataset="$ctrl.results"
|
||||||
|
table-key="edge-job-results"
|
||||||
|
order-by="Status"
|
||||||
|
reverse-order="true"
|
||||||
|
refresh-callback="$ctrl.refresh"
|
||||||
|
on-download-logs-click="($ctrl.downloadLogs)"
|
||||||
|
on-collect-logs-click="($ctrl.collectLogs)"
|
||||||
|
on-clear-logs-click="($ctrl.clearLogs)"
|
||||||
|
></edge-job-results-datatable>
|
||||||
|
</uib-tab>
|
||||||
|
</uib-tabset>
|
||||||
|
</rd-widget-body>
|
||||||
|
</rd-widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,7 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
import EdgeJobController from './edgeJobController';
|
||||||
|
|
||||||
|
angular.module('portainer.edge').component('edgeJobView', {
|
||||||
|
templateUrl: './edgeJob.html',
|
||||||
|
controller: EdgeJobController,
|
||||||
|
});
|
|
@ -0,0 +1,159 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
import _ from 'lodash-es';
|
||||||
|
|
||||||
|
class EdgeJobController {
|
||||||
|
constructor($async, $q, $state, EdgeJobService, EndpointService, FileSaver, GroupService, HostBrowserService, Notifications, TagService) {
|
||||||
|
this.state = {
|
||||||
|
actionInProgress: false,
|
||||||
|
showEditorTab: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.$async = $async;
|
||||||
|
this.$q = $q;
|
||||||
|
this.$state = $state;
|
||||||
|
this.EdgeJobService = EdgeJobService;
|
||||||
|
this.EndpointService = EndpointService;
|
||||||
|
this.FileSaver = FileSaver;
|
||||||
|
this.GroupService = GroupService;
|
||||||
|
this.HostBrowserService = HostBrowserService;
|
||||||
|
this.Notifications = Notifications;
|
||||||
|
this.TagService = TagService;
|
||||||
|
|
||||||
|
this.update = this.update.bind(this);
|
||||||
|
this.updateAsync = this.updateAsync.bind(this);
|
||||||
|
this.downloadLogs = this.downloadLogs.bind(this);
|
||||||
|
this.downloadLogsAsync = this.downloadLogsAsync.bind(this);
|
||||||
|
this.collectLogs = this.collectLogs.bind(this);
|
||||||
|
this.collectLogsAsync = this.collectLogsAsync.bind(this);
|
||||||
|
this.clearLogs = this.clearLogs.bind(this);
|
||||||
|
this.clearLogsAsync = this.clearLogsAsync.bind(this);
|
||||||
|
this.refresh = this.refresh.bind(this);
|
||||||
|
this.refreshAsync = this.refreshAsync.bind(this);
|
||||||
|
this.showEditor = this.showEditor.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
return this.$async(this.updateAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAsync() {
|
||||||
|
const model = this.edgeJob;
|
||||||
|
this.state.actionInProgress = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.EdgeJobService.updateEdgeJob(model);
|
||||||
|
this.Notifications.success('Edge job successfully updated');
|
||||||
|
this.$state.go('edge.jobs', {}, { reload: true });
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to update Edge job');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.actionInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadLogs(endpointId) {
|
||||||
|
return this.$async(this.downloadLogsAsync, endpointId);
|
||||||
|
}
|
||||||
|
async downloadLogsAsync(endpointId) {
|
||||||
|
try {
|
||||||
|
const data = await this.EdgeJobService.logFile(this.edgeJob.Id, endpointId);
|
||||||
|
const downloadData = new Blob([data.FileContent], {
|
||||||
|
type: 'text/plain;charset=utf-8',
|
||||||
|
});
|
||||||
|
const logFileName = `job_${this.edgeJob.Id}_task_${endpointId}.log`;
|
||||||
|
this.FileSaver.saveAs(downloadData, logFileName);
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to download file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
associateEndpointsToResults(results, endpoints) {
|
||||||
|
return _.map(results, (result) => {
|
||||||
|
const endpoint = _.find(endpoints, (endpoint) => endpoint.Id === result.EndpointId);
|
||||||
|
result.Endpoint = endpoint;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
collectLogs(endpointId) {
|
||||||
|
return this.$async(this.collectLogsAsync, endpointId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async collectLogsAsync(endpointId) {
|
||||||
|
try {
|
||||||
|
await this.EdgeJobService.collectLogs(this.edgeJob.Id, endpointId);
|
||||||
|
const result = _.find(this.results, (result) => result.EndpointId === endpointId);
|
||||||
|
result.LogsStatus = 2;
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to collect logs');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLogs(endpointId) {
|
||||||
|
return this.$async(this.clearLogsAsync, endpointId);
|
||||||
|
}
|
||||||
|
async clearLogsAsync(endpointId) {
|
||||||
|
try {
|
||||||
|
await this.EdgeJobService.clearLogs(this.edgeJob.Id, endpointId);
|
||||||
|
const result = _.find(this.results, (result) => result.EndpointId === endpointId);
|
||||||
|
result.LogsStatus = 1;
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to clear logs');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
return this.$async(this.refreshAsync);
|
||||||
|
}
|
||||||
|
async refreshAsync() {
|
||||||
|
const { id } = this.$state.params;
|
||||||
|
const results = await this.EdgeJobService.jobResults(id);
|
||||||
|
if (results.length > 0) {
|
||||||
|
const endpointIds = _.map(results, (result) => result.EndpointId);
|
||||||
|
const endpoints = await this.EndpointService.endpoints(undefined, undefined, { endpointIds });
|
||||||
|
this.results = this.associateEndpointsToResults(results, endpoints.value);
|
||||||
|
} else {
|
||||||
|
this.results = results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showEditor() {
|
||||||
|
this.state.showEditorTab = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async $onInit() {
|
||||||
|
const { id, tab } = this.$state.params;
|
||||||
|
this.state.activeTab = tab;
|
||||||
|
if (!tab || tab === 0) {
|
||||||
|
this.state.showEditorTab = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [edgeJob, file, results, groups, tags] = await Promise.all([
|
||||||
|
this.EdgeJobService.edgeJob(id),
|
||||||
|
this.EdgeJobService.getScriptFile(id),
|
||||||
|
this.EdgeJobService.jobResults(id),
|
||||||
|
this.GroupService.groups(),
|
||||||
|
this.TagService.tags(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
edgeJob.FileContent = file.FileContent;
|
||||||
|
this.edgeJob = edgeJob;
|
||||||
|
this.groups = groups;
|
||||||
|
this.tags = tags;
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
const endpointIds = _.map(results, (result) => result.EndpointId);
|
||||||
|
const endpoints = await this.EndpointService.endpoints(undefined, undefined, { endpointIds });
|
||||||
|
this.results = this.associateEndpointsToResults(results, endpoints.value);
|
||||||
|
} else {
|
||||||
|
this.results = results;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to retrieve endpoint list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
angular.module('portainer.edge').controller('EdgeJobController', EdgeJobController);
|
||||||
|
export default EdgeJobController;
|
|
@ -14,7 +14,12 @@ class EdgeGroupsController {
|
||||||
}
|
}
|
||||||
|
|
||||||
async $onInit() {
|
async $onInit() {
|
||||||
this.items = await this.EdgeGroupService.groups();
|
try {
|
||||||
|
this.items = await this.EdgeGroupService.groups();
|
||||||
|
} catch (err) {
|
||||||
|
this.items = [];
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAction(selectedItems) {
|
removeAction(selectedItems) {
|
||||||
|
|
|
@ -316,39 +316,6 @@ angular.module('portainer.app', []).config([
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
var schedules = {
|
|
||||||
name: 'portainer.schedules',
|
|
||||||
url: '/schedules',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
templateUrl: './views/schedules/schedules.html',
|
|
||||||
controller: 'SchedulesController',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
var schedule = {
|
|
||||||
name: 'portainer.schedules.schedule',
|
|
||||||
url: '/:id',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
templateUrl: './views/schedules/edit/schedule.html',
|
|
||||||
controller: 'ScheduleController',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
var scheduleCreation = {
|
|
||||||
name: 'portainer.schedules.new',
|
|
||||||
url: '/new',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
templateUrl: './views/schedules/create/createschedule.html',
|
|
||||||
controller: 'CreateScheduleController',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
var settings = {
|
var settings = {
|
||||||
name: 'portainer.settings',
|
name: 'portainer.settings',
|
||||||
url: '/settings',
|
url: '/settings',
|
||||||
|
@ -542,9 +509,6 @@ angular.module('portainer.app', []).config([
|
||||||
$stateRegistryProvider.register(registry);
|
$stateRegistryProvider.register(registry);
|
||||||
$stateRegistryProvider.register(registryAccess);
|
$stateRegistryProvider.register(registryAccess);
|
||||||
$stateRegistryProvider.register(registryCreation);
|
$stateRegistryProvider.register(registryCreation);
|
||||||
$stateRegistryProvider.register(schedules);
|
|
||||||
$stateRegistryProvider.register(schedule);
|
|
||||||
$stateRegistryProvider.register(scheduleCreation);
|
|
||||||
$stateRegistryProvider.register(settings);
|
$stateRegistryProvider.register(settings);
|
||||||
$stateRegistryProvider.register(settingsAuthentication);
|
$stateRegistryProvider.register(settingsAuthentication);
|
||||||
$stateRegistryProvider.register(stacks);
|
$stateRegistryProvider.register(stacks);
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
<div class="col-sm-12 small text-muted">
|
||||||
|
You can select which endpoint should be part of this group by moving them to the associated endpoints table. Simply click on any endpoint entry to move it from one table to the
|
||||||
|
other.
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12" style="margin-top: 20px;">
|
||||||
|
<!-- available-endpoints -->
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<div class="text-center small text-muted">Available endpoints</div>
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<group-association-table
|
||||||
|
loaded="$ctrl.loaded"
|
||||||
|
page-type="$ctrl.pageType"
|
||||||
|
table-type="available"
|
||||||
|
retrieve-page="$ctrl.getPaginatedEndpoints"
|
||||||
|
dataset="$ctrl.endpoints.available"
|
||||||
|
entry-click="$ctrl.associateEndpoint"
|
||||||
|
pagination-state="$ctrl.state.available"
|
||||||
|
empty-dataset-message="No endpoint available"
|
||||||
|
tags="$ctrl.tags"
|
||||||
|
show-tags="true"
|
||||||
|
groups="$ctrl.groups"
|
||||||
|
show-groups="true"
|
||||||
|
has-backend-pagination="true"
|
||||||
|
></group-association-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !available-endpoints -->
|
||||||
|
<!-- associated-endpoints -->
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<div class="text-center small text-muted">Associated endpoints</div>
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<group-association-table
|
||||||
|
loaded="$ctrl.loaded"
|
||||||
|
page-type="$ctrl.pageType"
|
||||||
|
table-type="associated"
|
||||||
|
retrieve-page="$ctrl.getPaginatedEndpoints"
|
||||||
|
dataset="$ctrl.endpoints.associated"
|
||||||
|
entry-click="$ctrl.dissociateEndpoint"
|
||||||
|
pagination-state="$ctrl.state.associated"
|
||||||
|
empty-dataset-message="No associated endpoint"
|
||||||
|
tags="$ctrl.tags"
|
||||||
|
show-tags="true"
|
||||||
|
groups="$ctrl.groups"
|
||||||
|
show-groups="true"
|
||||||
|
has-backend-pagination="true"
|
||||||
|
></group-association-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !associated-endpoints -->
|
||||||
|
</div>
|
|
@ -0,0 +1,16 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
import AssociatedEndpointsSelectorController from './associatedEndpointsSelectorController';
|
||||||
|
|
||||||
|
angular.module('portainer.app').component('associatedEndpointsSelector', {
|
||||||
|
templateUrl: './associatedEndpointsSelector.html',
|
||||||
|
controller: AssociatedEndpointsSelectorController,
|
||||||
|
bindings: {
|
||||||
|
endpointIds: '<',
|
||||||
|
tags: '<',
|
||||||
|
groups: '<',
|
||||||
|
hasBackendPagination: '<',
|
||||||
|
|
||||||
|
onAssociate: '<',
|
||||||
|
onDissociate: '<',
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,106 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
import _ from 'lodash-es';
|
||||||
|
|
||||||
|
class AssoicatedEndpointsSelectorController {
|
||||||
|
/* @ngInject */
|
||||||
|
constructor($async, EndpointService) {
|
||||||
|
this.$async = $async;
|
||||||
|
this.EndpointService = EndpointService;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
available: {
|
||||||
|
limit: '10',
|
||||||
|
filter: '',
|
||||||
|
pageNumber: 1,
|
||||||
|
totalCount: 0,
|
||||||
|
},
|
||||||
|
associated: {
|
||||||
|
limit: '10',
|
||||||
|
filter: '',
|
||||||
|
pageNumber: 1,
|
||||||
|
totalCount: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.endpoints = {
|
||||||
|
associated: [],
|
||||||
|
available: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.getEndpoints = this.getEndpoints.bind(this);
|
||||||
|
this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
|
||||||
|
this.getAssociatedEndpoints = this.getAssociatedEndpoints.bind(this);
|
||||||
|
this.getAssociatedEndpointsAsync = this.getAssociatedEndpointsAsync.bind(this);
|
||||||
|
this.associateEndpoint = this.associateEndpoint.bind(this);
|
||||||
|
this.dissociateEndpoint = this.dissociateEndpoint.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
$onInit() {
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
$onChanges({ endpointIds }) {
|
||||||
|
if (endpointIds && endpointIds.currentValue) {
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData() {
|
||||||
|
this.getAssociatedEndpoints();
|
||||||
|
this.getEndpoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
getEndpoints() {
|
||||||
|
return this.$async(this.getEndpointsAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEndpointsAsync() {
|
||||||
|
const { start, search, limit } = this.getPaginationData('available');
|
||||||
|
const query = { search, type: 4 };
|
||||||
|
|
||||||
|
const response = await this.EndpointService.endpoints(start, limit, query);
|
||||||
|
|
||||||
|
const endpoints = _.filter(response.value, (endpoint) => !_.includes(this.endpointIds, endpoint.Id));
|
||||||
|
this.setTableData('available', endpoints, response.totalCount);
|
||||||
|
this.noEndpoints = this.state.available.totalCount === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAssociatedEndpoints() {
|
||||||
|
return this.$async(this.getAssociatedEndpointsAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAssociatedEndpointsAsync() {
|
||||||
|
let response = { value: [], totalCount: 0 };
|
||||||
|
if (this.endpointIds.length > 0) {
|
||||||
|
const { start, search, limit } = this.getPaginationData('associated');
|
||||||
|
const query = { search, type: 4, endpointIds: this.endpointIds };
|
||||||
|
|
||||||
|
response = await this.EndpointService.endpoints(start, limit, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setTableData('associated', response.value, response.totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
associateEndpoint(endpoint) {
|
||||||
|
this.onAssociate(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
dissociateEndpoint(endpoint) {
|
||||||
|
this.onDissociate(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPaginationData(tableType) {
|
||||||
|
const { pageNumber, limit, search } = this.state[tableType];
|
||||||
|
const start = (pageNumber - 1) * limit + 1;
|
||||||
|
|
||||||
|
return { start, search, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
setTableData(tableType, endpoints, totalCount) {
|
||||||
|
this.endpoints[tableType] = endpoints;
|
||||||
|
this.state[tableType].totalCount = parseInt(totalCount, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
angular.module('portainer.app').controller('AssoicatedEndpointsSelectorController', AssoicatedEndpointsSelectorController);
|
||||||
|
export default AssoicatedEndpointsSelectorController;
|
|
@ -0,0 +1,8 @@
|
||||||
|
<information-panel title-text="Information">
|
||||||
|
<span class="small">
|
||||||
|
<p class="text-muted">
|
||||||
|
<i class="fa fa-flask orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
This is a beta feature.
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</information-panel>
|
|
@ -0,0 +1,3 @@
|
||||||
|
angular.module('portainer.app').component('betaPanel', {
|
||||||
|
templateUrl: './betaPanel.html',
|
||||||
|
});
|
|
@ -1,105 +0,0 @@
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
|
|
||||||
<div class="datatable">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body classes="no-padding">
|
|
||||||
<div class="toolBar">
|
|
||||||
<div class="toolBarTitle">
|
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i>
|
|
||||||
{{ $ctrl.titleText }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="searchBar">
|
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }" />
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover table-filters nowrap-cells">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>
|
|
||||||
<a ng-click="$ctrl.changeOrderBy('Endpoint')">
|
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Endpoint' && !$ctrl.state.reverseOrder"></i>
|
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Endpoint' && $ctrl.state.reverseOrder"></i>
|
|
||||||
Endpoint
|
|
||||||
</a>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<a ng-click="$ctrl.changeOrderBy('Id')">
|
|
||||||
Id
|
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && !$ctrl.state.reverseOrder"></i>
|
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && $ctrl.state.reverseOrder"></i>
|
|
||||||
</a>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<a ng-click="$ctrl.changeOrderBy('Status')">
|
|
||||||
State
|
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && !$ctrl.state.reverseOrder"></i>
|
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && $ctrl.state.reverseOrder"></i>
|
|
||||||
</a>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<a ng-click="$ctrl.changeOrderBy('Created')">
|
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && !$ctrl.state.reverseOrder"></i>
|
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i>
|
|
||||||
Created
|
|
||||||
</a>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
{{ item.Endpoint.Name }}
|
|
||||||
<a ng-if="item.Edge" ng-click="$ctrl.getEdgeTaskLogs(item.EndpointId, item.Id)"
|
|
||||||
><i class="fa fa-download" aria-hidden="true" style="margin-left: 5px; margin-right: 2px;"></i> Download logs</a
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<a ng-if="!item.Edge" ng-click="$ctrl.goToContainerLogs(item.EndpointId, item.Id)">{{ item.Id | truncate: 32 }}</a>
|
|
||||||
<span ng-if="item.Edge">-</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span ng-if="!item.Edge" class="label label-{{ item.Status | containerstatusbadge }}">{{ item.Status }}</span>
|
|
||||||
<span ng-if="item.Edge">-</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span ng-if="!item.Edge">{{ item.Created | getisodatefromtimestamp }}</span>
|
|
||||||
<span ng-if="item.Edge">-</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="!$ctrl.dataset">
|
|
||||||
<td colspan="9" class="text-center text-muted">Loading...</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
|
||||||
<td colspan="9" class="text-center text-muted">No tasks available.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="footer" ng-if="$ctrl.dataset">
|
|
||||||
<div class="paginationControls">
|
|
||||||
<form class="form-inline">
|
|
||||||
<span class="limitSelector">
|
|
||||||
<span style="margin-right: 5px;">
|
|
||||||
Items per page
|
|
||||||
</span>
|
|
||||||
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
|
|
||||||
<option value="0">All</option>
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="25">25</option>
|
|
||||||
<option value="50">50</option>
|
|
||||||
<option value="100">100</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<dir-pagination-controls max-size="5"></dir-pagination-controls>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,13 +0,0 @@
|
||||||
angular.module('portainer.app').component('schedulesDatatable', {
|
|
||||||
templateUrl: './schedulesDatatable.html',
|
|
||||||
controller: 'SchedulesDatatableController',
|
|
||||||
bindings: {
|
|
||||||
titleText: '@',
|
|
||||||
titleIcon: '@',
|
|
||||||
dataset: '<',
|
|
||||||
tableKey: '@',
|
|
||||||
orderBy: '@',
|
|
||||||
reverseOrder: '<',
|
|
||||||
removeAction: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,48 +0,0 @@
|
||||||
angular.module('portainer.app').controller('SchedulesDatatableController', [
|
|
||||||
'$scope',
|
|
||||||
'$controller',
|
|
||||||
'DatatableService',
|
|
||||||
function ($scope, $controller, DatatableService) {
|
|
||||||
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do not allow items
|
|
||||||
*/
|
|
||||||
this.allowSelection = function (item) {
|
|
||||||
return item.JobType === 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.$onInit = function () {
|
|
||||||
this.setDefaults();
|
|
||||||
this.prepareTableFromDataset();
|
|
||||||
|
|
||||||
this.state.orderBy = this.orderBy;
|
|
||||||
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
|
|
||||||
if (storedOrder !== null) {
|
|
||||||
this.state.reverseOrder = storedOrder.reverse;
|
|
||||||
this.state.orderBy = storedOrder.orderBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
|
|
||||||
if (textFilter !== null) {
|
|
||||||
this.state.textFilter = textFilter;
|
|
||||||
this.onTextFilterChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
|
|
||||||
if (storedFilters !== null) {
|
|
||||||
this.filters = storedFilters;
|
|
||||||
}
|
|
||||||
if (this.filters && this.filters.state) {
|
|
||||||
this.filters.state.open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
|
|
||||||
if (storedSettings !== null) {
|
|
||||||
this.settings = storedSettings;
|
|
||||||
this.settings.open = false;
|
|
||||||
}
|
|
||||||
this.onSettingsRepeaterChange();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -1,74 +0,0 @@
|
||||||
angular.module('portainer.app').controller('JobFormController', [
|
|
||||||
'$state',
|
|
||||||
'LocalStorage',
|
|
||||||
'EndpointService',
|
|
||||||
'EndpointProvider',
|
|
||||||
'Notifications',
|
|
||||||
function ($state, LocalStorage, EndpointService, EndpointProvider, Notifications) {
|
|
||||||
var ctrl = this;
|
|
||||||
|
|
||||||
ctrl.$onInit = onInit;
|
|
||||||
ctrl.editorUpdate = editorUpdate;
|
|
||||||
ctrl.executeJob = executeJob;
|
|
||||||
|
|
||||||
ctrl.state = {
|
|
||||||
Method: 'editor',
|
|
||||||
formValidationError: '',
|
|
||||||
actionInProgress: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
ctrl.formValues = {
|
|
||||||
Image: 'ubuntu:latest',
|
|
||||||
JobFileContent: '',
|
|
||||||
JobFile: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function onInit() {
|
|
||||||
var storedImage = LocalStorage.getJobImage();
|
|
||||||
if (storedImage) {
|
|
||||||
ctrl.formValues.Image = storedImage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function editorUpdate(cm) {
|
|
||||||
ctrl.formValues.JobFileContent = cm.getValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createJob(image, method) {
|
|
||||||
var endpointId = EndpointProvider.endpointID();
|
|
||||||
var nodeName = ctrl.nodeName;
|
|
||||||
|
|
||||||
if (method === 'editor') {
|
|
||||||
var jobFileContent = ctrl.formValues.JobFileContent;
|
|
||||||
return EndpointService.executeJobFromFileContent(image, jobFileContent, endpointId, nodeName);
|
|
||||||
}
|
|
||||||
|
|
||||||
var jobFile = ctrl.formValues.JobFile;
|
|
||||||
return EndpointService.executeJobFromFileUpload(image, jobFile, endpointId, nodeName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function executeJob() {
|
|
||||||
var method = ctrl.state.Method;
|
|
||||||
if (method === 'editor' && ctrl.formValues.JobFileContent === '') {
|
|
||||||
ctrl.state.formValidationError = 'Script file content must not be empty';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var image = ctrl.formValues.Image;
|
|
||||||
LocalStorage.storeJobImage(image);
|
|
||||||
|
|
||||||
ctrl.state.actionInProgress = true;
|
|
||||||
createJob(image, method)
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Job successfully created');
|
|
||||||
$state.go('^');
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Job execution failure', err);
|
|
||||||
})
|
|
||||||
.finally(function final() {
|
|
||||||
ctrl.state.actionInProgress = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -1,109 +0,0 @@
|
||||||
<form class="form-horizontal" name="executeJobForm">
|
|
||||||
<!-- image-input -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="job_image" class="col-sm-1 control-label text-left">Image</label>
|
|
||||||
<div class="col-sm-11">
|
|
||||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.Image" id="job_image" name="job_image" placeholder="e.g. ubuntu:latest" required auto-focus />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" ng-show="executeJobForm.job_image.$invalid">
|
|
||||||
<div class="col-sm-12 small text-warning">
|
|
||||||
<div ng-messages="executeJobForm.job_image.$error">
|
|
||||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !image-input -->
|
|
||||||
<div class="form-group">
|
|
||||||
<span class="col-sm-12 text-muted small">
|
|
||||||
This job will run inside a privileged container on the host. You can access the host filesystem under the
|
|
||||||
<code>/host</code> folder.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- execution-method -->
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Job creation
|
|
||||||
</div>
|
|
||||||
<div class="form-group"></div>
|
|
||||||
<div class="form-group" style="margin-bottom: 0;">
|
|
||||||
<div class="boxselector_wrapper">
|
|
||||||
<div>
|
|
||||||
<input type="radio" id="method_editor" ng-model="$ctrl.state.Method" value="editor" />
|
|
||||||
<label for="method_editor">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
|
|
||||||
Web editor
|
|
||||||
</div>
|
|
||||||
<p>Use our Web editor</p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input type="radio" id="method_upload" ng-model="$ctrl.state.Method" value="upload" />
|
|
||||||
<label for="method_upload">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
|
|
||||||
Upload
|
|
||||||
</div>
|
|
||||||
<p>Upload from your computer</p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !execution-method -->
|
|
||||||
<!-- web-editor -->
|
|
||||||
<div ng-show="$ctrl.state.Method === 'editor'">
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Web editor
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<code-editor identifier="execute-job-editor" placeholder="# Define or paste the content of your script file here" on-change="($ctrl.editorUpdate)"> </code-editor>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !web-editor -->
|
|
||||||
<!-- upload -->
|
|
||||||
<div ng-show="$ctrl.state.Method === 'upload'">
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Upload
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<span class="col-sm-12 text-muted small">
|
|
||||||
You can upload a script file from your computer.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formValues.JobFile">Select file</button>
|
|
||||||
<span style="margin-left: 5px;">
|
|
||||||
{{ $ctrl.formValues.JobFile.name }}
|
|
||||||
<i class="fa fa-times red-icon" ng-if="!$ctrl.formValues.JobFile" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !upload -->
|
|
||||||
<!-- actions -->
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Actions
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
ng-disabled="$ctrl.state.actionInProgress || !executeJobForm.$valid
|
|
||||||
|| ($ctrl.state.Method === 'upload' && !$ctrl.formValues.JobFile)"
|
|
||||||
ng-click="$ctrl.executeJob()"
|
|
||||||
button-spinner="$ctrl.state.actionInProgress"
|
|
||||||
>
|
|
||||||
<span ng-hide="$ctrl.state.actionInProgress">Execute</span>
|
|
||||||
<span ng-show="$ctrl.state.actionInProgress">Starting job...</span>
|
|
||||||
</button>
|
|
||||||
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
|
|
||||||
{{ $ctrl.state.formValidationError }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !actions -->
|
|
||||||
</form>
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue