feat: add schedule functionality

This commit is contained in:
Denis Gukov 2021-09-06 16:05:10 +05:00
parent b77ffbfab8
commit 6eeb6706d4
20 changed files with 504 additions and 47 deletions

149
api/projects/schedules.go Normal file
View File

@ -0,0 +1,149 @@
package projects
import (
log "github.com/Sirupsen/logrus"
"github.com/ansible-semaphore/semaphore/api/helpers"
"github.com/ansible-semaphore/semaphore/db"
"github.com/gorilla/context"
"net/http"
"strconv"
)
// SchedulesMiddleware ensures a template exists and loads it to the context
func SchedulesMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
scheduleID, err := helpers.GetIntParam("schedule_id", w, r)
if err != nil {
return
}
schedule, err := helpers.Store(r).GetSchedule(project.ID, scheduleID)
if err != nil {
helpers.WriteError(w, err)
return
}
context.Set(r, "schedule", schedule)
next.ServeHTTP(w, r)
})
}
// GetSchedule returns single template by ID
func GetSchedule(w http.ResponseWriter, r *http.Request) {
schedule := context.Get(r, "schedule").(db.Schedule)
helpers.WriteJSON(w, http.StatusOK, schedule)
}
// AddSchedule adds a template to the database
func AddSchedule(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
var schedule db.Schedule
if !helpers.Bind(w, r, &schedule) {
return
}
schedule.ProjectID = project.ID
schedule, err := helpers.Store(r).CreateSchedule(schedule)
if err != nil {
helpers.WriteError(w, err)
return
}
user := context.Get(r, "user").(*db.User)
objType := "schedule"
desc := "Schedule ID " + strconv.Itoa(schedule.ID) + " created"
_, err = helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
ProjectID: &project.ID,
ObjectType: &objType,
ObjectID: &schedule.ID,
Description: &desc,
})
if err != nil {
log.Error(err)
}
helpers.WriteJSON(w, http.StatusCreated, schedule)
}
// UpdateSchedule writes a schedule to an existing key in the database
func UpdateSchedule(w http.ResponseWriter, r *http.Request) {
oldSchedule := context.Get(r, "schedule").(db.Schedule)
var schedule db.Schedule
if !helpers.Bind(w, r, &schedule) {
return
}
// project ID and schedule ID in the body and the path must be the same
if schedule.ID != oldSchedule.ID {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "schedule id in URL and in body must be the same",
})
return
}
if schedule.ProjectID != oldSchedule.ProjectID {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "You can not move schedule to other project",
})
return
}
err := helpers.Store(r).UpdateSchedule(schedule)
if err != nil {
helpers.WriteError(w, err)
return
}
user := context.Get(r, "user").(*db.User)
desc := "Schedule ID " + strconv.Itoa(schedule.ID) + " updated"
objType := "schedule"
_, err = helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
ProjectID: &schedule.ProjectID,
Description: &desc,
ObjectID: &schedule.ID,
ObjectType: &objType,
})
if err != nil {
log.Error(err)
}
w.WriteHeader(http.StatusNoContent)
}
// RemoveSchedule deletes a schedule from the database
func RemoveSchedule(w http.ResponseWriter, r *http.Request) {
schedule := context.Get(r, "schedule").(db.Schedule)
err := helpers.Store(r).DeleteSchedule(schedule.ProjectID, schedule.ID)
if err != nil {
helpers.WriteError(w, err)
return
}
user := context.Get(r, "user").(*db.User)
desc := "Schedule ID " + strconv.Itoa(schedule.ID) + " deleted"
_, err = helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
ProjectID: &schedule.ProjectID,
Description: &desc,
})
if err != nil {
log.Error(err)
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -4,10 +4,9 @@ import (
log "github.com/Sirupsen/logrus"
"github.com/ansible-semaphore/semaphore/api/helpers"
"github.com/ansible-semaphore/semaphore/db"
"github.com/gorilla/context"
"net/http"
"strconv"
"github.com/gorilla/context"
)
// TemplatesMiddleware ensures a template exists and loads it to the context
@ -42,7 +41,7 @@ func GetTemplates(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
params := db.RetrieveQueryParams{
SortBy: r.URL.Query().Get("sort"),
SortBy: r.URL.Query().Get("sort"),
SortInverted: r.URL.Query().Get("order") == desc,
}
@ -102,9 +101,17 @@ func UpdateTemplate(w http.ResponseWriter, r *http.Request) {
}
// project ID and template ID in the body and the path must be the same
if template.ID != oldTemplate.ID || template.ProjectID != oldTemplate.ProjectID {
if template.ID != oldTemplate.ID {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "You can not move ",
"error": "template id in URL and in body must be the same",
})
return
}
if template.ProjectID != oldTemplate.ProjectID {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "You can not move template to other project",
})
return
}
@ -125,7 +132,7 @@ func UpdateTemplate(w http.ResponseWriter, r *http.Request) {
objType := "template"
_, err = helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
UserID: &user.ID,
ProjectID: &template.ProjectID,
Description: &desc,
ObjectID: &template.ID,
@ -162,4 +169,4 @@ func RemoveTemplate(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
}

View File

@ -189,6 +189,7 @@ func Route() *mux.Router {
projectTmplManagement.HandleFunc("/{template_id}", projects.GetTemplate).Methods("GET")
projectTmplManagement.HandleFunc("/{template_id}/tasks", tasks.GetAllTasks).Methods("GET")
projectTmplManagement.HandleFunc("/{template_id}/tasks/last", tasks.GetLastTasks).Methods("GET")
projectTmplManagement.HandleFunc("/{template_id}/schedules", projects.GetSchedule).Methods("GET")
projectTaskManagement := projectUserAPI.PathPrefix("/tasks").Subrouter()
projectTaskManagement.Use(tasks.GetTaskMiddleware)
@ -198,6 +199,13 @@ func Route() *mux.Router {
projectTaskManagement.HandleFunc("/{task_id}", tasks.RemoveTask).Methods("DELETE")
projectTaskManagement.HandleFunc("/{task_id}/stop", tasks.StopTask).Methods("POST")
projectScheduleManagement := projectUserAPI.PathPrefix("/schedules").Subrouter()
projectScheduleManagement.Use(projects.SchedulesMiddleware)
projectScheduleManagement.HandleFunc("/{schedule_id}", projects.GetSchedule).Methods("GET", "HEAD")
projectScheduleManagement.HandleFunc("/{schedule_id}", projects.UpdateSchedule).Methods("PUT")
projectScheduleManagement.HandleFunc("/{schedule_id}", projects.RemoveSchedule).Methods("DELETE")
if os.Getenv("DEBUG") == "1" {
defer debugPrintRoutes(r)
}

63
api/schedules/pool.go Normal file
View File

@ -0,0 +1,63 @@
package schedules
import (
"github.com/ansible-semaphore/semaphore/db"
"github.com/robfig/cron/v3"
)
type templateRunner struct {
schedule db.Schedule
}
func (r templateRunner) Run() {
// TODO: add task to tasks pool
}
type schedulePool struct {
cron *cron.Cron
jobs []cron.EntryID
}
func (p *schedulePool) init() {
p.cron = cron.New()
}
func (p *schedulePool) loadData(d db.Store) {
schedules, err := d.GetSchedules()
if err != nil {
// TODO: log error
return
}
for _, schedule := range schedules {
err := p.addSchedule(schedule)
if err != nil {
// TODO: log error
}
}
}
func (p *schedulePool) addSchedule(schedule db.Schedule) error {
id, err := p.cron.AddJob(schedule.CronFormat, templateRunner{
schedule: schedule,
})
if err != nil {
return err
}
p.jobs = append(p.jobs, id)
return nil
}
func (p *schedulePool) run() {
p.cron.Run()
}
var pool = schedulePool{}
// StartRunner begins the schedule pool, used as a goroutine
func StartRunner(d db.Store) {
pool.init()
pool.loadData(d)
pool.run()
}

View File

@ -4,7 +4,11 @@ import (
"bytes"
"encoding/json"
"fmt"
log "github.com/Sirupsen/logrus"
"github.com/ansible-semaphore/semaphore/api/helpers"
"github.com/ansible-semaphore/semaphore/api/sockets"
"github.com/ansible-semaphore/semaphore/db"
"github.com/ansible-semaphore/semaphore/util"
"io/ioutil"
"os"
"os/exec"
@ -12,13 +16,6 @@ import (
"strconv"
"strings"
"time"
"github.com/ansible-semaphore/semaphore/api/helpers"
"github.com/ansible-semaphore/semaphore/db"
log "github.com/Sirupsen/logrus"
"github.com/ansible-semaphore/semaphore/util"
)
const (
@ -32,19 +29,19 @@ const (
)
type task struct {
store db.Store
task db.Task
template db.Template
inventory db.Inventory
repository db.Repository
environment db.Environment
users []int
projectID int
hosts []string
alertChat string
alert bool
prepared bool
process *os.Process
store db.Store
task db.Task
template db.Template
inventory db.Inventory
repository db.Repository
environment db.Environment
users []int
projectID int
hosts []string
alertChat string
alert bool
prepared bool
process *os.Process
}
func (t *task) getRepoName() string {

View File

@ -4,6 +4,7 @@ import (
"fmt"
log "github.com/Sirupsen/logrus"
"github.com/ansible-semaphore/semaphore/api"
"github.com/ansible-semaphore/semaphore/api/schedules"
"github.com/ansible-semaphore/semaphore/api/sockets"
"github.com/ansible-semaphore/semaphore/api/tasks"
"github.com/ansible-semaphore/semaphore/db"
@ -74,6 +75,7 @@ func runService() {
go sockets.StartWS()
go tasks.StartRunner()
go schedules.StartRunner(store)
route := api.Route()

8
db/Schedule.go Normal file
View File

@ -0,0 +1,8 @@
package db
type Schedule struct {
ID int `db:"id" json:"id"`
ProjectID int `db:"project_id" json:"project_id"`
TemplateID int `db:"template_id" json:"template_id"`
CronFormat string `db:"cron_format" json:"cron_format"`
}

View File

@ -43,6 +43,8 @@ func ValidateUsername(login string) error {
return nil
}
type Transaction interface {}
type Store interface {
Connect() error
Close() error
@ -107,6 +109,13 @@ type Store interface {
GetTemplate(projectID int, templateID int) (Template, error)
DeleteTemplate(projectID int, templateID int) error
GetSchedules() ([]Schedule, error)
GetTemplateSchedules(projectID int, templateID int) ([]Schedule, error)
CreateSchedule(schedule Schedule) (Schedule, error)
UpdateSchedule(schedule Schedule) error
GetSchedule(projectID int, scheduleID int) (Schedule, error)
DeleteSchedule(projectID int, scheduleID int) error
GetProjectUsers(projectID int, params RetrieveQueryParams) ([]User, error)
CreateProjectUser(projectUser ProjectUser) (ProjectUser, error)
DeleteProjectUser(projectID int, userID int) error
@ -274,6 +283,11 @@ var TemplateProps = ObjectProperties{
PrimaryColumnName: "id",
}
var ScheduleProps = ObjectProperties{
TableName: "project__schedule",
PrimaryColumnName: "id",
}
var ProjectUserProps = ObjectProperties{
TableName: "project__user",
PrimaryColumnName: "user_id",
@ -310,5 +324,4 @@ var TaskProps = ObjectProperties{
var TaskOutputProps = ObjectProperties{
TableName: "task__output",
PrimaryColumnName: "",
}

View File

@ -24,4 +24,4 @@ type Template struct {
VaultPassID *int `db:"vault_pass_id" json:"vault_pass_id"`
VaultPass AccessKey `db:"-" json:"-"`
}
}

48
db/bolt/schedule.go Normal file
View File

@ -0,0 +1,48 @@
package bolt
import "github.com/ansible-semaphore/semaphore/db"
func (d *BoltDb) GetSchedules() (schedules []db.Schedule, err error) {
var allProjects []db.Project
err = d.getObjects(0, db.ProjectProps, db.RetrieveQueryParams{}, nil, &allProjects)
if err != nil {
return
}
for _, proj := range allProjects {
var projSchedules []db.Schedule
projSchedules, err = d.GetProjectSchedules(proj.ID)
if err != nil {
return
}
schedules = append(schedules, projSchedules...)
}
return
}
func (d *BoltDb) GetProjectSchedules(projectID int) (schedules []db.Schedule, err error) {
err = d.getObjects(projectID, db.ScheduleProps, db.RetrieveQueryParams{}, nil, &schedules)
return
}
func (d *BoltDb) GetTemplateSchedules(projectID int, templateID int) (schedule db.Schedule, err error) {
projSchedules, err := d.GetProjectSchedules(projectID)
if err != nil {
return
}
for _, s := range projSchedules {
if s.TemplateID == templateID {
schedule = s
return
}
}
err = db.ErrNotFound
return
}

View File

@ -81,5 +81,6 @@ func init() {
{Major: 2, Minor: 7, Patch: 9},
{Major: 2, Minor: 7, Patch: 10},
{Major: 2, Minor: 7, Patch: 12},
{Major: 2, Minor: 7, Patch: 13},
}
}

View File

@ -71,7 +71,8 @@ func (d *SqlDb) applyMigration(version *Version) error {
}
q := d.prepareMigration(query)
if _, err := tx.Exec(q); err != nil {
_, err = tx.Exec(q)
if err != nil {
handleRollbackError(tx.Rollback())
log.Warnf("\n ERR! Query: %v\n\n", q)
return err

View File

@ -0,0 +1,3 @@
alter table project__template_schedule rename to project__schedule;
alter table `project__schedule` add `id` integer primary key autoincrement;
alter table `project__schedule` add `project_id` int not null references project(`id`);

67
db/sql/schedule.go Normal file
View File

@ -0,0 +1,67 @@
package sql
import (
"database/sql"
"github.com/ansible-semaphore/semaphore/db"
)
func (d *SqlDb) CreateSchedule(schedule db.Schedule) (newSchedule db.Schedule, err error) {
insertID, err := d.insert(
"id",
"insert into project__schedule (project_id, template_id, cron_format)" +
"values (?, ?, ?)",
schedule.ProjectID,
schedule.TemplateID,
schedule.CronFormat)
if err != nil {
return
}
newSchedule = schedule
newSchedule.ID = insertID
return
}
func (d *SqlDb) UpdateSchedule(schedule db.Schedule) error {
_, err := d.exec("update project__schedule set cron_format=? where project_id=? and id=?",
schedule.CronFormat,
schedule.ProjectID,
schedule.ID)
return err
}
func (d *SqlDb) GetSchedule(projectID int, scheduleID int) (template db.Schedule, err error) {
err = d.selectOne(
&template,
"select * from project__schedule where project_id=? and id=?",
projectID,
scheduleID)
if err == sql.ErrNoRows {
err = db.ErrNotFound
}
return
}
func (d *SqlDb) DeleteSchedule(projectID int, scheduleID int) error {
_, err := d.exec("delete project__schedule where project_id=? and id=?", projectID, scheduleID)
return err
}
func (d *SqlDb) GetSchedules() (schedules []db.Schedule, err error) {
_, err = d.selectAll(&schedules, "select * from project__template_schedule where cron_format != ''")
return
}
func (d *SqlDb) GetTemplateSchedules(projectID int, templateID int) (schedules []db.Schedule, err error) {
_, err = d.selectAll(&schedules,
"select * from project__template_schedule where project_id=? and template_id=?",
projectID,
templateID)
return
}

View File

@ -24,15 +24,21 @@ func (d *SqlDb) CreateTemplate(template db.Template) (newTemplate db.Template, e
return
}
err = db.FillTemplate(d, &newTemplate)
if err != nil {
return
}
newTemplate = template
newTemplate.ID = insertID
err = db.FillTemplate(d, &newTemplate)
return
}
func (d *SqlDb) UpdateTemplate(template db.Template) error {
_, err := d.exec("update project__template set inventory_id=?, repository_id=?, environment_id=?, alias=?, " +
"playbook=?, arguments=?, override_args=? where removed = false and id=?",
"playbook=?, arguments=?, override_args=? where removed = false and id=? and project_id=?",
template.InventoryID,
template.RepositoryID,
template.EnvironmentID,
@ -40,8 +46,33 @@ func (d *SqlDb) UpdateTemplate(template db.Template) error {
template.Playbook,
template.Arguments,
template.OverrideArguments,
template.ID)
template.ID,
template.ProjectID)
//if err != nil {
// return err
//}
//
//if template.CronFormat == "" {
// _, err = d.exec(
// "delete from project__template_schedule where project_id =? and template_id=?",
// template.ProjectID,
// template.ID)
//} else {
// _, err = d.GetTemplateSchedules(template.ProjectID, template.ID)
// if err == nil {
// _, err = d.exec(
// "update project__template_schedule set cron_format=? where project_id =? and template_id=?",
// template.CronFormat,
// template.ProjectID,
// template.ID)
// } else if err == db.ErrNotFound {
// _, err = d.exec(
// "insert into project__template_schedule (template_id, cron_format) values (?, ?)",
// template.ID,
// template.CronFormat)
// }
//}
return err
}
@ -117,11 +148,4 @@ func (d *SqlDb) DeleteTemplate(projectID int, templateID int) error {
_, err := d.exec("update project__template set removed=true where project_id=? and id=?", projectID, templateID)
return err
//res, err := d.exec(
// "delete from project__template where project_id=? and id=?",
// projectID,
// templateID)
//return validateMutationResult(res, err)
}

1
go.mod
View File

@ -40,6 +40,7 @@ require (
github.com/onsi/ginkgo v1.12.0 // indirect
github.com/onsi/gomega v1.9.0 // indirect
github.com/radovskyb/watcher v1.0.7 // indirect
github.com/robfig/cron/v3 v3.0.1
github.com/russross/blackfriday v1.5.2 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/snikch/goodman v0.0.0-20171125024755-10e37e294daa

2
go.sum
View File

@ -379,6 +379,8 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=

View File

@ -762,11 +762,11 @@ export default {
if (!this.isAuthenticated) {
return;
}
this.user = (await axios({
method: 'get',
url: '/api/user',
responseType: 'json',
})).data;
this.user = (await axios({
method: 'get',
url: '/api/user',
responseType: 'json',
})).data;
},
getProjectColor(projectData) {

View File

@ -79,6 +79,10 @@ export default {
throw new Error('Not implemented'); // must me implemented in template
},
afterSave() {
},
getNewItem() {
return {};
},
@ -142,6 +146,8 @@ export default {
...(this.getRequestOptions()),
})).data;
await this.afterSave();
this.$emit('save', {
item: item || this.item,
action: this.isNew ? 'new' : 'edit',

View File

@ -67,6 +67,13 @@
:disabled="formSaving"
></v-select>
<v-text-field
v-model="cronFormat"
label="Cron"
:disabled="formSaving"
placeholder="Example: * 1 * * * *"
v-if="schedules == null || schedules.length === 1"
></v-text-field>
</v-col>
<v-col cols="12" md="6" class="pb-0">
@ -134,6 +141,8 @@ export default {
inventory: null,
repositories: null,
environment: null,
schedules: null,
cronFormat: null,
};
},
@ -177,6 +186,14 @@ export default {
url: `/api/project/${this.projectId}/environment`,
responseType: 'json',
})).data;
this.schedules = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates/${this.sourceItemId}/schedules`,
responseType: 'json',
})).data;
if (this.schedules.length === 1) {
this.cronFormat = this.schedules[0].cron_format;
}
},
computed: {
@ -189,7 +206,8 @@ export default {
&& this.repositories != null
&& this.inventory != null
&& this.environment != null
&& this.item != null;
&& this.item != null
&& this.schedules != null;
},
loginPasswordKeys() {
@ -208,6 +226,45 @@ export default {
getSingleItemUrl() {
return `/api/project/${this.projectId}/templates/${this.itemId}`;
},
async afterSave(newItem) {
if (newItem || this.schedules.length === 0) {
if (this.cronFormat !== '') {
// new schedule
await axios({
method: 'post',
url: `/api/project/${this.projectId}/schedules`,
responseType: 'json',
data: {
project_id: this.projectId,
template_id: newItem ? newItem.id : this.itemId,
cron_format: this.cronFormat,
},
});
}
} else if (this.schedules.length > 1) {
// do nothing
} else if (this.schedules === '') {
// drop schedule
await axios({
method: 'delete',
url: `/api/project/${this.projectId}/schedules/${this.schedules[0].id}`,
responseType: 'json',
});
} else {
// update schedule
await axios({
method: 'put',
url: `/api/project/${this.projectId}/schedules/${this.schedules[0].id}`,
responseType: 'json',
data: {
project_id: this.projectId,
template_id: this.itemId,
cron_format: this.cronFormat,
},
});
}
},
},
};
</script>