Merge branch 'develop' into develop

This commit is contained in:
Denis Gukov 2021-10-30 01:07:52 +05:00 committed by GitHub
commit 3a6bc0f7b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 5568 additions and 1703 deletions

View File

@ -22,7 +22,8 @@ aliases:
run:
name: install task binary
# subshell prevents potentially unwanted cwd change
command: (cd $HOME && (curl -sL https://taskfile.dev/install.sh | sh))
command: go get github.com/go-task/task/v3/cmd/task
# command: (cd $HOME && (curl -sL https://taskfile.dev/install.sh | ~))
- &persist-from-build
persist_to_workspace:
@ -55,8 +56,7 @@ aliases:
run:
name: test that compile did not create/modify untracked files
command: |
cat web2/package.json
git diff --exit-code --stat -- . ':(exclude)web2/package-lock.json' ':(exclude)web/package-lock.json' ':(exclude)go.mod' ':(exclude)go.sum'
git diff --exit-code --stat -- . ':(exclude)web2/package.json' ':(exclude)web2/package-lock.json' ':(exclude)go.mod' ':(exclude)go.sum'
- &save-npm-cache
save_cache:
@ -161,7 +161,8 @@ jobs:
path: /go/src/github.com/ansible-semaphore/semaphore/coverage.out
test:integration:
machine: true
machine:
image: ubuntu-2004:202107-02
steps:
- checkout
- *install-task-binary

31
.dredd/dredd.windows.yml Normal file
View File

@ -0,0 +1,31 @@
dry-run: null
hookfiles: ./.dredd/compiled_hooks.exe
language: go
#server: context=dev task dc:up
server-wait: 240
init: false
custom: {}
names: false
only: []
reporter: []
output: []
header: "Authorization: bearer h4a_i4qslpnxyyref71rk5nqbwxccrs7enwvggx0vfs="
sorted: false
user: null
inline-errors: false
details: false
method: []
color: true
loglevel: debug
path: []
hooks-worker-timeout: 5000
hooks-worker-connect-timeout: 1500
hooks-worker-connect-retry: 500
hooks-worker-after-connect-wait: 100
hooks-worker-term-timeout: 5000
hooks-worker-term-retry: 500
hooks-worker-handler-host: 0.0.0.0
hooks-worker-handler-port: 61321
config: ./.dredd/dredd.yml
blueprint: api-docs.yml
endpoint: 'http://localhost:3000'

View File

@ -10,12 +10,14 @@ import (
)
// STATE
// Runtime created objects we needs to reference in test setups
// Runtime created objects we need to reference in test setups
var testRunnerUser *db.User
var userPathTestUser *db.User
var userProject *db.Project
var userKey *db.AccessKey
var task *db.Task
var schedule *db.Schedule
var view *db.View
// Runtime created simple ID values for some items we need to reference in other objects
var repoID int64
@ -26,12 +28,13 @@ var templateID int64
var capabilities = map[string][]string{
"user": {},
"project": {"user"},
//"access_key": {"project"},
"repository": {"access_key"},
"inventory": {"repository"},
"environment": {"repository"},
"template": {"repository", "inventory", "environment"},
"task": {"template"},
"template": {"repository", "inventory", "environment", "view"},
"task": {"template"},
"schedule": {"template"},
"view": {},
}
func capabilityWrapper(cap string) func(t *trans.Transaction) {
@ -63,6 +66,10 @@ func resolveCapability(caps []string, resolved []string, uid string) {
//Add dep specific stuff
switch v {
case "schedule":
schedule = addSchedule()
case "view":
view = addView()
case "user":
userPathTestUser = addUser()
case "project":
@ -92,10 +99,10 @@ func resolveCapability(caps []string, resolved []string, uid string) {
environmentID, _ = res.LastInsertId()
case "template":
res, err := store.Sql().Exec(
"insert into project__template " +
"(project_id, inventory_id, repository_id, environment_id, alias, playbook, arguments, override_args) " +
"values (?, ?, ?, ?, ?, ?, ?, ?)",
userProject.ID, inventoryID, repoID, environmentID, "Test-"+uid, "test-playbook.yml", "", false)
"insert into project__template "+
"(project_id, inventory_id, repository_id, environment_id, alias, playbook, arguments, override_args, description, view_id) "+
"values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
userProject.ID, inventoryID, repoID, environmentID, "Test-"+uid, "test-playbook.yml", "", false, "Hello, World!", view.ID)
printError(err)
templateID, _ = res.LastInsertId()
case "task":
@ -122,6 +129,8 @@ var pathSubPatterns = []func() string{
func() string { return strconv.Itoa(int(environmentID)) },
func() string { return strconv.Itoa(int(templateID)) },
func() string { return strconv.Itoa(task.ID) },
func() string { return strconv.Itoa(schedule.ID) },
func() string { return strconv.Itoa(view.ID) },
}
// alterRequestPath with the above slice of functions
@ -157,7 +166,12 @@ func alterRequestBody(t *trans.Transaction) {
if task != nil {
bodyFieldProcessor("task_id", task.ID, &request)
}
if schedule != nil {
bodyFieldProcessor("schedule_id", schedule.ID, &request)
}
if view != nil {
bodyFieldProcessor("view_id", view.ID, &request)
}
// Inject object ID to body for PUT requests
if strings.ToLower(t.Request.Method) == "put" {
putRequestPathRE := regexp.MustCompile(`/api/(?:project/\d+/)?\w+/(\d+)/?$`)

View File

@ -25,9 +25,10 @@ var tablesShouldBeTruncated = [...]string {
"project__inventory",
"project__repository",
"project__template",
"project__template_schedule",
"project__schedule",
"project__user",
"user",
"project__view",
}
// Test Runner User
func addTestRunnerUser() {
@ -138,6 +139,36 @@ func addUser() *db.User {
return &user
}
func addView() *db.View {
view, err := store.CreateView(db.View{
ProjectID: userProject.ID,
Title: "Test",
Position: 1,
})
if err != nil {
fmt.Println(err)
}
return &view
}
func addSchedule() *db.Schedule {
schedule, err := store.CreateSchedule(db.Schedule{
TemplateID: int(templateID),
CronFormat: "* * * 1 *",
ProjectID: userProject.ID,
})
if err != nil {
fmt.Println(err)
}
return &schedule
}
func addTask() *db.Task {
t := db.Task{
TemplateID: int(templateID),

View File

@ -105,6 +105,14 @@ func main() {
h.Before("project > /api/project/{project_id}/tasks/{task_id} > Deletes task (including output) > 204 > application/json", capabilityWrapper("task"))
h.Before("project > /api/project/{project_id}/tasks/{task_id}/output > Get task output > 200 > application/json", capabilityWrapper("task"))
h.Before("schedule > /api/project/{project_id}/schedules/{schedule_id} > Get schedule > 200 > application/json", capabilityWrapper("schedule"))
h.Before("schedule > /api/project/{project_id}/schedules/{schedule_id} > Updates schedule > 204 > application/json", capabilityWrapper("schedule"))
h.Before("schedule > /api/project/{project_id}/schedules/{schedule_id} > Deletes schedule > 204 > application/json", capabilityWrapper("schedule"))
h.Before("project > /api/project/{project_id}/views/{view_id} > Get view > 200 > application/json", capabilityWrapper("view"))
h.Before("project > /api/project/{project_id}/views/{view_id} > Updates view > 204 > application/json", capabilityWrapper("view"))
h.Before("project > /api/project/{project_id}/views/{view_id} > Removes view > 204 > application/json", capabilityWrapper("view"))
//Add these last as they normalize the requests and path values after hook processing
h.BeforeAll(func(transactions []*trans.Transaction) {
for _, t := range transactions {

1
.gitignore vendored
View File

@ -24,6 +24,7 @@ util/version.go
!.gitkeep
.dredd/compiled_hooks
.dredd/compiled_hooks.exe
.vscode
__debug_bin*

View File

@ -1,17 +1,35 @@
![semaphore](https://user-images.githubusercontent.com/914224/125253358-c214ed80-e312-11eb-952e-d96a1eba93f6.png)
# Ansible Semaphore
[![Circle CI](https://circleci.com/gh/ansible-semaphore/semaphore.svg?style=svg&circle-token=3702872acf2bec629017fa7dd99fdbea56aef7df)](https://circleci.com/gh/ansible-semaphore/semaphore)
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/89e0129c6ba64fe2b1ebe983f72a4eff)](https://www.codacy.com/app/ansible-semaphore/semaphore?utm_source=github.com&utm_medium=referral&utm_content=ansible-semaphore/semaphore&utm_campaign=Badge_Grade)
[![Codacy Badge](https://api.codacy.com/project/badge/Coverage/89e0129c6ba64fe2b1ebe983f72a4eff)](https://www.codacy.com/app/ansible-semaphore/semaphore?utm_source=github.com&utm_medium=referral&utm_content=ansible-semaphore/semaphore&utm_campaign=Badge_Coverage)
[![semaphore on discord](https://img.shields.io/badge/discord-semaphore%20community-738bd7.svg)](https://discord.gg/GXXTBVz)
[![Join the chat at https://gitter.im/AnsibleSemaphore/semaphore](https://badges.gitter.im/AnsibleSemaphore/semaphore.svg)](https://gitter.im/AnsibleSemaphore/semaphore?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
Follow Semaphore on Twitter ([AnsibleSem](https://twitter.com/AnsibleSem)) and StackShare ([ansible-semaphore](https://stackshare.io/ansible-semaphore)).
Ansible Semaphore is a modern UI for Ansible. It lets you easily run Ansible playbooks, get notifications about fails, control access to deployment system.
If your project has grown and deploying from the terminal is no longer for you then Ansible Semaphore is what you need.
![responsive-ui-phone1](https://user-images.githubusercontent.com/914224/134777345-8789d9e4-ff0d-439c-b80e-ddc56b74fcee.png)
<!--
![image](https://user-images.githubusercontent.com/914224/134411082-48235676-06d2-4d4b-b674-4ffe1e8d0d0d.png)
![semaphore](https://user-images.githubusercontent.com/914224/125253358-c214ed80-e312-11eb-952e-d96a1eba93f6.png)
-->
<!--
- [Releases](https://github.com/ansible-semaphore/semaphore/releases)
- [Installation](https://docs.ansible-semaphore.com/administration-guide/installation)
- [Docker Hub](https://hub.docker.com/r/ansiblesemaphore/semaphore/)
- [Install Instructions](https://github.com/ansible-semaphore/semaphore/wiki/Installation)
- [Contribution](https://github.com/ansible-semaphore/semaphore/blob/develop/CONTRIBUTING.md)
- [Troubleshooting](https://github.com/ansible-semaphore/semaphore/wiki/Troubleshooting)
- [Contribution Guide](https://github.com/ansible-semaphore/semaphore/blob/develop/CONTRIBUTING.md)
- [Roadmap](https://github.com/ansible-semaphore/semaphore/projects)
- [UI Walkthrough](https://blog.strangeman.info/ansible/2017/08/05/semaphore-ui-guide.html) (external blog)
-->
## Release Signing
@ -20,7 +38,7 @@ All releases after 2.5.1 are signed with the gpg public key
## Installation
https://github.com/ansible-semaphore/semaphore/wiki/Installation
https://docs.ansible-semaphore.com/administration-guide/installation
[![Get it from the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/semaphore)
@ -30,6 +48,12 @@ https://demo.ansible-semaphore.com
Login / password: `demo / demo`.
## Docs
Admin and user docs: https://docs.ansible-semaphore.com
API docs: https://ansible-semaphore.github.io/semaphore
## Contributing
PR's & UX reviews are welcome!

View File

@ -117,7 +117,7 @@ tasks:
compile:api:hooks:
dir: ./.dredd/hooks
cmds:
- go build -o ../compiled_hooks
- go build -o ../compiled_hooks{{ if eq OS "windows" }}.exe{{ end }}
build:
desc: Build a full set of release binaries and packages

View File

@ -302,14 +302,18 @@ definitions:
environment_id:
type: integer
minimum: 1
# vault_pass_id:
# type: integer
view_id:
type: integer
minimum: 1
alias:
type: string
playbook:
type: string
arguments:
type: string
description:
type: string
example: Hello, World!
override_args:
type: boolean
Template:
@ -329,17 +333,73 @@ definitions:
environment_id:
type: integer
minimum: 1
# vault_pass_id:
# type: integer
view_id:
type: integer
minimum: 1
alias:
type: string
playbook:
type: string
arguments:
type: string
description:
type: string
example: Hello, World!
override_args:
type: boolean
ScheduleRequest:
type: object
properties:
id:
type: integer
cron_format:
type: string
x-example: "* * * 1 *"
example: "* * * 1 *"
project_id:
type: integer
template_id:
type: integer
Schedule:
type: object
properties:
id:
type: integer
cron_format:
type: string
project_id:
type: integer
template_id:
type: integer
ViewRequest:
type: object
properties:
title:
type: string
example: Test
project_id:
type: integer
minimum: 1
position:
type: integer
minimum: 1
View:
type: object
properties:
id:
type: integer
title:
type: string
project_id:
type: integer
position:
type: integer
Event:
type: object
properties:
@ -442,7 +502,20 @@ parameters:
type: integer
required: true
x-example: 8
schedule_id:
name: schedule_id
description: schedule ID
in: path
type: integer
required: true
x-example: 9
view_id:
name: view_id
description: view ID
in: path
type: integer
required: true
x-example: 10
paths:
/ping:
get:
@ -482,30 +555,13 @@ paths:
schema:
$ref: "#/definitions/InfoType"
# /upgrade:
# get:
# summary: Check if new updates available and fetch /info
# responses:
# 204:
# description: no update
# 200:
# description: ok
# schema:
# $ref: "#/definitions/InfoType"
# post:
# summary: Upgrade the server
# responses:
# 200:
# description: Server binary was replaced by new version, server has shut down.
# Authentication
/auth/login:
post:
tags:
- authentication
summary: Performs Login
description: |
Upon success you will be logged in
description: Upon success you will be logged in
security: [] # No security
parameters:
- name: Login Body
@ -1199,6 +1255,127 @@ paths:
204:
description: template removed
# project schedules
/project/{project_id}/schedules/{schedule_id}:
parameters:
- $ref: "#/parameters/project_id"
- $ref: "#/parameters/schedule_id"
get:
tags:
- schedule
summary: Get schedule
responses:
200:
description: Schedule
schema:
$ref: "#/definitions/Schedule"
delete:
tags:
- schedule
summary: Deletes schedule
responses:
204:
description: schedule deleted
put:
tags:
- schedule
summary: Updates schedule
parameters:
- name: schedule
in: body
required: true
schema:
$ref: "#/definitions/ScheduleRequest"
responses:
204:
description: schedule updated
/project/{project_id}/schedules:
parameters:
- $ref: "#/parameters/project_id"
post:
tags:
- schedule
summary: create schedule
parameters:
- name: schedule
in: body
required: true
schema:
$ref: "#/definitions/ScheduleRequest"
responses:
201:
description: schedule created
schema:
$ref: "#/definitions/Schedule"
# project views
/project/{project_id}/views:
parameters:
- $ref: "#/parameters/project_id"
get:
tags:
- project
summary: Get view
responses:
200:
description: view
schema:
type: array
items:
$ref: "#/definitions/View"
post:
tags:
- project
summary: create view
parameters:
- name: view
in: body
required: true
schema:
$ref: "#/definitions/ViewRequest"
responses:
201:
description: view created
schema:
$ref: "#/definitions/View"
/project/{project_id}/views/{view_id}:
parameters:
- $ref: "#/parameters/project_id"
- $ref: "#/parameters/view_id"
get:
tags:
- project
summary: Get view
responses:
200:
description: view object
schema:
$ref: "#/definitions/View"
put:
tags:
- project
summary: Updates view
parameters:
- name: view
in: body
required: true
schema:
$ref: "#/definitions/ViewRequest"
responses:
204:
description: view updated
delete:
tags:
- project
summary: Removes view
responses:
204:
description: view removed
# tasks
/project/{project_id}/tasks:
parameters:

View File

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"runtime/debug"
"strconv"
@ -99,3 +100,10 @@ func GetMD5Hash(filepath string) (string, error) {
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
func QueryParams(url *url.URL) db.RetrieveQueryParams {
return db.RetrieveQueryParams{
SortBy: url.Query().Get("sort"),
SortInverted: url.Query().Get("order") == "desc",
}
}

View File

@ -21,12 +21,22 @@ func findLDAPUser(username, password string) (*db.User, error) {
return nil, fmt.Errorf("LDAP not configured")
}
l, err := ldap.Dial("tcp", util.Config.LdapServer)
var l *ldap.Conn
var err error
if util.Config.LdapNeedTLS {
l, err = ldap.DialTLS("tcp", util.Config.LdapServer, &tls.Config{
InsecureSkipVerify: true,
})
} else {
l, err = ldap.Dial("tcp", util.Config.LdapServer)
}
if err != nil {
return nil, err
}
defer l.Close()
// Reconnect with TLS if needed
if util.Config.LdapNeedTLS {
// TODO: InsecureSkipVerify should be configurable
@ -58,7 +68,7 @@ func findLDAPUser(username, password string) (*db.User, error) {
}
if len(sr.Entries) != 1 {
return nil, fmt.Errorf("User does not exist or too many entries returned")
return nil, fmt.Errorf("user does not exist or too many entries returned")
}
// Bind as the user to verify their password

View File

@ -43,12 +43,7 @@ func GetEnvironment(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
params := db.RetrieveQueryParams{
SortBy: r.URL.Query().Get("sort"),
SortInverted: r.URL.Query().Get("order") == desc,
}
env, err := helpers.Store(r).GetEnvironments(project.ID, params)
env, err := helpers.Store(r).GetEnvironments(project.ID, helpers.QueryParams(r.URL))
if err != nil {
helpers.WriteError(w, err)
@ -127,7 +122,7 @@ func AddEnvironment(w http.ResponseWriter, r *http.Request) {
user := context.Get(r, "user").(*db.User)
objType := "environment"
objType := db.EventEnvironment
desc := "Environment " + newEnv.Name + " created"
_, err = helpers.Store(r).CreateEvent(db.Event{

View File

@ -13,11 +13,6 @@ import (
"github.com/gorilla/context"
)
const (
//asc = "asc"
desc = "desc"
)
// InventoryMiddleware ensures an inventory exists and loads it to the context
func InventoryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -48,12 +43,7 @@ func GetInventory(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
params := db.RetrieveQueryParams{
SortBy: r.URL.Query().Get("sort"),
SortInverted: r.URL.Query().Get("order") == desc,
}
inventories, err := helpers.Store(r).GetInventories(project.ID, params)
inventories, err := helpers.Store(r).GetInventories(project.ID, helpers.QueryParams(r.URL))
if err != nil {
helpers.WriteError(w, err)
@ -81,7 +71,7 @@ func AddInventory(w http.ResponseWriter, r *http.Request) {
}
switch inventory.Type {
case "static", "file":
case db.InventoryStatic, db.InventoryFile:
break
default:
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
@ -99,7 +89,7 @@ func AddInventory(w http.ResponseWriter, r *http.Request) {
user := context.Get(r, "user").(*db.User)
objType := "inventory"
objType := db.EventInventory
desc := "Inventory " + inventory.Name + " created"
_, err = helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
@ -163,9 +153,9 @@ func UpdateInventory(w http.ResponseWriter, r *http.Request) {
}
switch inventory.Type {
case "static":
case db.InventoryStatic:
break
case "file":
case db.InventoryFile:
if !IsValidInventoryPath(inventory.Inventory) {
w.WriteHeader(http.StatusBadRequest)
return

View File

@ -1,8 +1,8 @@
package projects
import (
"testing"
"runtime"
"testing"
)
func TestIsValidInventoryPath(t *testing.T) {

View File

@ -34,7 +34,6 @@ func KeyMiddleware(next http.Handler) http.Handler {
func GetKeys(w http.ResponseWriter, r *http.Request) {
if key := context.Get(r, "accessKey"); key != nil {
k := key.(db.AccessKey)
k.ResetSecret()
helpers.WriteJSON(w, http.StatusOK, k)
return
}
@ -42,16 +41,7 @@ func GetKeys(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
var keys []db.AccessKey
params := db.RetrieveQueryParams{
SortBy: r.URL.Query().Get("sort"),
SortInverted: r.URL.Query().Get("order") == desc,
}
keys, err := helpers.Store(r).GetAccessKeys(project.ID, params)
for _, k := range keys {
k.ResetSecret()
}
keys, err := helpers.Store(r).GetAccessKeys(project.ID, helpers.QueryParams(r.URL))
if err != nil {
helpers.WriteError(w, err)
@ -93,7 +83,7 @@ func AddKey(w http.ResponseWriter, r *http.Request) {
user := context.Get(r, "user").(*db.User)
objType := "key"
objType := db.EventKey
desc := "Access Key " + key.Name + " created"
_, err = helpers.Store(r).CreateEvent(db.Event{
@ -121,11 +111,6 @@ func UpdateKey(w http.ResponseWriter, r *http.Request) {
return
}
if err := key.Validate(oldKey.OverrideSecret); err != nil {
helpers.WriteError(w, err)
return
}
if err := helpers.Store(r).UpdateAccessKey(key); err != nil {
helpers.WriteError(w, err)
return
@ -134,7 +119,7 @@ func UpdateKey(w http.ResponseWriter, r *http.Request) {
user := context.Get(r, "user").(*db.User)
desc := "Access Key " + key.Name + " updated"
objType := "key"
objType := db.EventKey
_, err := helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
@ -166,7 +151,7 @@ func RemoveKey(w http.ResponseWriter, r *http.Request) {
err = helpers.Store(r).DeleteAccessKey(*key.ProjectID, key.ID)
if err == db.ErrInvalidOperation {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]interface{}{
"error": "Inventory is in use by one or more templates",
"error": "Access Key is in use by one or more templates",
"inUse": true,
})
return

View File

@ -47,7 +47,7 @@ func AddProject(w http.ResponseWriter, r *http.Request) {
}
desc := "Project Created"
oType := "Project"
oType := db.EventProject
_, err = helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
ProjectID: &body.ID,

View File

@ -68,12 +68,7 @@ func GetRepositories(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
params := db.RetrieveQueryParams{
SortBy: r.URL.Query().Get("sort"),
SortInverted: r.URL.Query().Get("order") == desc,
}
repos, err := helpers.Store(r).GetRepositories(project.ID, params)
repos, err := helpers.Store(r).GetRepositories(project.ID, helpers.QueryParams(r.URL))
if err != nil {
helpers.WriteError(w, err)
@ -108,7 +103,7 @@ func AddRepository(w http.ResponseWriter, r *http.Request) {
user := context.Get(r, "user").(*db.User)
objType := "repository"
objType := db.EventRepository
desc := "Repository (" + repository.GitURL + ") created"
_, err = helpers.Store(r).CreateEvent(db.Event{
@ -163,7 +158,7 @@ func UpdateRepository(w http.ResponseWriter, r *http.Request) {
user := context.Get(r, "user").(*db.User)
desc := "Repository (" + repository.GitURL + ") updated"
objType := "repository"
objType := db.EventRepository
_, err = helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,

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

@ -0,0 +1,205 @@
package projects
import (
log "github.com/Sirupsen/logrus"
"github.com/ansible-semaphore/semaphore/api/helpers"
"github.com/ansible-semaphore/semaphore/api/schedules"
"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 { // not specified schedule_id
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)
})
}
func refreshSchedulePool(r *http.Request) {
pool := context.Get(r, "schedule_pool").(schedules.SchedulePool)
pool.Refresh(helpers.Store(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)
}
func GetTemplateSchedules(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
templateID, err := helpers.GetIntParam("template_id", w, r)
if err != nil {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "template_id must be provided",
})
return
}
tplSchedules, err := helpers.Store(r).GetTemplateSchedules(project.ID, templateID)
if err != nil {
helpers.WriteError(w, err)
return
}
helpers.WriteJSON(w, http.StatusOK, tplSchedules)
}
func validateCronFormat(cronFormat string, w http.ResponseWriter) bool {
err := schedules.ValidateCronFormat(cronFormat)
if err == nil {
return true
}
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "Cron: " + err.Error(),
})
return false
}
func ValidateScheduleCronFormat(w http.ResponseWriter, r *http.Request) {
var schedule db.Schedule
if !helpers.Bind(w, r, &schedule) {
return
}
_ = validateCronFormat(schedule.CronFormat, w)
}
// 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
}
if !validateCronFormat(schedule.CronFormat, w) {
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 := db.EventSchedule
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)
}
refreshSchedulePool(r)
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
}
if !validateCronFormat(schedule.CronFormat, w) {
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 := db.EventSchedule
_, 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)
}
refreshSchedulePool(r)
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)
}
refreshSchedulePool(r)
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
@ -41,12 +40,7 @@ func GetTemplate(w http.ResponseWriter, r *http.Request) {
func GetTemplates(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
params := db.RetrieveQueryParams{
SortBy: r.URL.Query().Get("sort"),
SortInverted: r.URL.Query().Get("order") == desc,
}
templates, err := helpers.Store(r).GetTemplates(project.ID, params)
templates, err := helpers.Store(r).GetTemplates(project.ID, helpers.QueryParams(r.URL))
if err != nil {
helpers.WriteError(w, err)
@ -74,7 +68,7 @@ func AddTemplate(w http.ResponseWriter, r *http.Request) {
}
user := context.Get(r, "user").(*db.User)
objType := "template"
objType := db.EventTemplate
desc := "Template ID " + strconv.Itoa(template.ID) + " created"
_, err = helpers.Store(r).CreateEvent(db.Event{
@ -102,9 +96,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
}
@ -122,10 +124,10 @@ func UpdateTemplate(w http.ResponseWriter, r *http.Request) {
user := context.Get(r, "user").(*db.User)
desc := "Template ID " + strconv.Itoa(template.ID) + " updated"
objType := "template"
objType := db.EventTemplate
_, err = helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
UserID: &user.ID,
ProjectID: &template.ProjectID,
Description: &desc,
ObjectID: &template.ID,
@ -162,4 +164,4 @@ func RemoveTemplate(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusNoContent)
}
}

View File

@ -48,12 +48,7 @@ func GetUsers(w http.ResponseWriter, r *http.Request) {
}
project := context.Get(r, "project").(db.Project)
params := db.RetrieveQueryParams{
SortBy: r.URL.Query().Get("sort"),
SortInverted: r.URL.Query().Get("order") == desc,
}
users, err := helpers.Store(r).GetProjectUsers(project.ID, params)
users, err := helpers.Store(r).GetProjectUsers(project.ID, helpers.QueryParams(r.URL))
if err != nil {
helpers.WriteError(w, err)
@ -83,7 +78,7 @@ func AddUser(w http.ResponseWriter, r *http.Request) {
}
user := context.Get(r, "user").(*db.User)
objType := "user"
objType := db.EventUser
desc := "User ID " + strconv.Itoa(projectUser.UserID) + " added to team"
_, err = helpers.Store(r).CreateEvent(db.Event{
@ -114,7 +109,7 @@ func RemoveUser(w http.ResponseWriter, r *http.Request) {
}
user := context.Get(r, "user").(*db.User)
objType := "user"
objType := db.EventUser
desc := "User ID " + strconv.Itoa(projectUser.ID) + " removed from team"
_, err = helpers.Store(r).CreateEvent(db.Event{

215
api/projects/views.go Normal file
View File

@ -0,0 +1,215 @@
package projects
import (
log "github.com/Sirupsen/logrus"
"github.com/ansible-semaphore/semaphore/api/helpers"
"github.com/ansible-semaphore/semaphore/db"
"net/http"
"github.com/gorilla/context"
)
// ViewMiddleware ensures a key exists and loads it to the context
func ViewMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
viewID, err := helpers.GetIntParam("view_id", w, r)
if err != nil {
return
}
view, err := helpers.Store(r).GetView(project.ID, viewID)
if err != nil {
helpers.WriteError(w, err)
return
}
context.Set(r, "view", view)
next.ServeHTTP(w, r)
})
}
func GetViewTemplates(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
view := context.Get(r, "view").(db.View)
templates, err := helpers.Store(r).GetViewTemplates(project.ID, view.ID, helpers.QueryParams(r.URL))
if err != nil {
helpers.WriteError(w, err)
return
}
helpers.WriteJSON(w, http.StatusOK, templates)
}
// GetViews retrieves sorted keys from the database
func GetViews(w http.ResponseWriter, r *http.Request) {
if view := context.Get(r, "view"); view != nil {
k := view.(db.View)
helpers.WriteJSON(w, http.StatusOK, k)
return
}
project := context.Get(r, "project").(db.Project)
var views []db.View
views, err := helpers.Store(r).GetViews(project.ID)
if err != nil {
helpers.WriteError(w, err)
return
}
helpers.WriteJSON(w, http.StatusOK, views)
}
// AddView adds a new key to the database
func AddView(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
var view db.View
if !helpers.Bind(w, r, &view) {
return
}
if view.ProjectID != project.ID {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "Project ID in body and URL must be the same",
})
return
}
if err := view.Validate(); err != nil {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
return
}
newView, err := helpers.Store(r).CreateView(view)
if err != nil {
helpers.WriteError(w, err)
return
}
user := context.Get(r, "user").(*db.User)
objType := db.EventKey
desc := "View " + view.Title + " created"
_, err = helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
ProjectID: &newView.ProjectID,
ObjectType: &objType,
ObjectID: &newView.ID,
Description: &desc,
})
if err != nil {
log.Error(err)
}
helpers.WriteJSON(w, http.StatusCreated, newView)
}
func SetViewPositions(w http.ResponseWriter, r *http.Request) {
var positions map[int]int
project := context.Get(r, "project").(db.Project)
if !helpers.Bind(w, r, &positions) {
return
}
err := helpers.Store(r).SetViewPositions(project.ID, positions)
if err != nil {
helpers.WriteError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateView updates key in database
// nolint: gocyclo
func UpdateView(w http.ResponseWriter, r *http.Request) {
var view db.View
oldView := context.Get(r, "view").(db.View)
if !helpers.Bind(w, r, &view) {
return
}
if view.ID != oldView.ID {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "View ID in URL and in body must be the same",
})
return
}
if err := view.Validate(); err != nil {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": err.Error(),
})
return
}
if err := helpers.Store(r).UpdateView(view); err != nil {
helpers.WriteError(w, err)
return
}
user := context.Get(r, "user").(*db.User)
desc := "View " + view.Title + " updated"
objType := db.EventView
_, err := helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
ProjectID: &oldView.ProjectID,
Description: &desc,
ObjectID: &oldView.ID,
ObjectType: &objType,
})
if err != nil {
log.Error(err)
}
w.WriteHeader(http.StatusNoContent)
}
// RemoveView deletes a view from the database
func RemoveView(w http.ResponseWriter, r *http.Request) {
view := context.Get(r, "view").(db.View)
var err error
err = helpers.Store(r).DeleteView(view.ProjectID, view.ID)
if err != nil {
helpers.WriteError(w, err)
return
}
user := context.Get(r, "user").(*db.User)
desc := "View " + view.Title + " deleted"
_, err = helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
ProjectID: &view.ProjectID,
Description: &desc,
})
if err != nil {
log.Error(err)
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -136,6 +136,13 @@ func Route() *mux.Router {
projectUserAPI.Path("/templates").HandlerFunc(projects.GetTemplates).Methods("GET", "HEAD")
projectUserAPI.Path("/templates").HandlerFunc(projects.AddTemplate).Methods("POST")
projectUserAPI.Path("/schedules").HandlerFunc(projects.AddSchedule).Methods("POST")
projectUserAPI.Path("/schedules/validate").HandlerFunc(projects.ValidateScheduleCronFormat).Methods("POST")
projectUserAPI.Path("/views").HandlerFunc(projects.GetViews).Methods("GET", "HEAD")
projectUserAPI.Path("/views").HandlerFunc(projects.AddView).Methods("POST")
projectUserAPI.Path("/views/positions").HandlerFunc(projects.SetViewPositions).Methods("POST")
projectAdminAPI := authenticatedAPI.Path("/project/{project_id}").Subrouter()
projectAdminAPI.Use(projects.ProjectMiddleware, projects.MustBeAdmin)
projectAdminAPI.Methods("PUT").HandlerFunc(projects.UpdateProject)
@ -189,6 +196,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.GetTemplateSchedules).Methods("GET")
projectTaskManagement := projectUserAPI.PathPrefix("/tasks").Subrouter()
projectTaskManagement.Use(tasks.GetTaskMiddleware)
@ -198,6 +206,21 @@ 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")
projectViewManagement := projectUserAPI.PathPrefix("/views").Subrouter()
projectViewManagement.Use(projects.ViewMiddleware)
projectViewManagement.HandleFunc("/{view_id}", projects.GetViews).Methods("GET", "HEAD")
projectViewManagement.HandleFunc("/{view_id}", projects.UpdateView).Methods("PUT")
projectViewManagement.HandleFunc("/{view_id}", projects.RemoveView).Methods("DELETE")
projectViewManagement.HandleFunc("/{view_id}/templates", projects.GetViewTemplates).Methods("GET", "HEAD")
if os.Getenv("DEBUG") == "1" {
defer debugPrintRoutes(r)
}
@ -332,4 +355,4 @@ func getSystemInfo(w http.ResponseWriter, r *http.Request) {
//}
helpers.WriteJSON(w, http.StatusOK, body)
}
}

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

@ -0,0 +1,97 @@
package schedules
import (
log "github.com/Sirupsen/logrus"
"github.com/ansible-semaphore/semaphore/api/tasks"
"github.com/ansible-semaphore/semaphore/db"
"github.com/robfig/cron/v3"
"sync"
)
type ScheduleRunner struct {
Store db.Store
Schedule db.Schedule
}
func (r ScheduleRunner) Run() {
_, err := tasks.AddTaskToPool(r.Store, db.Task{
TemplateID: r.Schedule.TemplateID,
ProjectID: r.Schedule.ProjectID,
}, nil, r.Schedule.ProjectID)
if err != nil {
log.Error(err)
}
}
type SchedulePool struct {
cron *cron.Cron
locker sync.Locker
}
func (p *SchedulePool) init() {
p.cron = cron.New()
p.locker = &sync.Mutex{}
}
func (p *SchedulePool) Refresh(d db.Store) {
defer p.locker.Unlock()
schedules, err := d.GetSchedules()
if err != nil {
log.Error(err)
return
}
p.locker.Lock()
p.clear()
for _, schedule := range schedules {
_, err := p.addRunner(ScheduleRunner{
Store: d,
Schedule: schedule,
})
if err != nil {
log.Error(err)
}
}
}
func (p *SchedulePool) addRunner(runner ScheduleRunner) (int, error) {
id, err := p.cron.AddJob(runner.Schedule.CronFormat, runner)
if err != nil {
return 0, err
}
return int(id), nil
}
func (p *SchedulePool) Run() {
p.cron.Run()
}
func (p *SchedulePool) clear() {
runners := p.cron.Entries()
for _, r := range runners {
p.cron.Remove(r.ID)
}
}
func (p *SchedulePool) Destroy() {
defer p.locker.Unlock()
p.locker.Lock()
p.cron.Stop()
p.clear()
p.cron = nil
}
func CreateSchedulePool(d db.Store) (pool SchedulePool) {
pool.init()
pool.Refresh(d)
return
}
func ValidateCronFormat(cronFormat string) error {
_, err := cron.ParseStandard(cronFormat)
return err
}

View File

@ -0,0 +1,15 @@
package schedules
import "testing"
func TestValidateCronFormat(t *testing.T) {
err := ValidateCronFormat("* * * *")
if err == nil {
t.Fatal("")
}
err = ValidateCronFormat("* * 1 * *")
if err != nil {
t.Fatal(err.Error())
}
}

View File

@ -5,6 +5,7 @@ import (
"html/template"
"net/http"
"strconv"
"strings"
"github.com/ansible-semaphore/semaphore/util"
)
@ -14,14 +15,18 @@ const emailTemplate = `Subject: Task '{{ .Alias }}' failed
Task {{ .TaskID }} with template '{{ .Alias }}' has failed!
Task log: <a href='{{ .TaskURL }}'>{{ .TaskURL }}</a>`
const telegramTemplate = `{"chat_id": "{{ .ChatID }}","text":"<b>Task {{ .TaskID }} with template '{{ .Alias }}' has failed!</b>\nTask log: <a href='{{ .TaskURL }}'>{{ .TaskURL }}</a>","parse_mode":"HTML"}`
const telegramTemplate = `{"chat_id": "{{ .ChatID }}","parse_mode":"HTML","text":"<code>{{ .Alias }}</code>\n#{{ .TaskID }} <b>{{ .TaskResult }}</b> <code>{{ .TaskVersion }}</code> {{ .TaskDescription }}\nby {{ .Author }}\nLink: {{ .TaskURL }}"}`
// Alert represents an alert that will be templated and sent to the appropriate service
type Alert struct {
TaskID string
Alias string
TaskURL string
ChatID string
TaskID string
Alias string
TaskURL string
ChatID string
TaskResult string
TaskDescription string
TaskVersion string
Author string
}
func (t *task) sendMailAlert() {
@ -49,10 +54,14 @@ func (t *task) sendMailAlert() {
if !userObj.Alert {
return
}
t.panicOnError(err,"Can't find user Email!")
t.panicOnError(err, "Can't find user Email!")
t.log("Sending email to " + userObj.Email + " from " + util.Config.EmailSender)
err = util.SendMail(mailHost, util.Config.EmailSender, userObj.Email, mailBuffer)
if util.Config.EmailSecure {
err = util.SendSecureMail(util.Config.EmailHost, util.Config.EmailPort, util.Config.EmailSender, util.Config.EmailUsername, util.Config.EmailPassword, userObj.Email, mailBuffer)
} else {
err = util.SendMail(mailHost, util.Config.EmailSender, userObj.Email, mailBuffer)
}
t.panicOnError(err, "Can't send email!")
}
}
@ -68,23 +77,60 @@ func (t *task) sendTelegramAlert() {
}
var telegramBuffer bytes.Buffer
var version string
if t.task.Version != nil {
version = *t.task.Version
} else if t.task.BuildTaskID != nil {
version = "build " + strconv.Itoa(*t.task.BuildTaskID)
} else {
version = ""
}
var message string
if t.task.Message != "" {
message = "- " + t.task.Message
}
var author string
if t.task.UserID != nil {
user, err := t.store.GetUser(*t.task.UserID)
if err != nil {
panic(err)
}
author = user.Name
}
alert := Alert{
TaskID: strconv.Itoa(t.task.ID),
Alias: t.template.Alias,
TaskURL: util.Config.WebHost + "/project/" + strconv.Itoa(t.template.ProjectID) + "/templates/" + strconv.Itoa(t.template.ID) + "?t=" + strconv.Itoa(t.task.ID),
ChatID: chatID,
TaskID: strconv.Itoa(t.task.ID),
Alias: t.template.Alias,
TaskURL: util.Config.WebHost + "/project/" + strconv.Itoa(t.template.ProjectID) + "/templates/" + strconv.Itoa(t.template.ID) + "?t=" + strconv.Itoa(t.task.ID),
ChatID: chatID,
TaskResult: strings.ToUpper(t.task.Status),
TaskVersion: version,
TaskDescription: message,
Author: author,
}
tpl := template.New("telegram body template")
tpl, err := tpl.Parse(telegramTemplate)
util.LogError(err)
t.panicOnError(tpl.Execute(&telegramBuffer, alert),"Can't generate alert template!")
tpl, err := tpl.Parse(telegramTemplate)
if err != nil {
t.log("Can't parse telegram template!")
panic(err)
}
err = tpl.Execute(&telegramBuffer, alert)
if err != nil {
t.log("Can't generate alert template!")
panic(err)
}
resp, err := http.Post("https://api.telegram.org/bot"+util.Config.TelegramToken+"/sendMessage", "application/json", &telegramBuffer)
t.panicOnError(err, "Can't send telegram alert!")
if resp.StatusCode != 200 {
if err != nil {
t.log("Can't send telegram alert! Response code not 200!")
} else if resp.StatusCode != 200 {
t.log("Can't send telegram alert! Response code not 200!")
}
}

View File

@ -4,7 +4,9 @@ import (
"github.com/ansible-semaphore/semaphore/api/helpers"
"github.com/ansible-semaphore/semaphore/db"
"net/http"
"regexp"
"strconv"
"strings"
"time"
log "github.com/Sirupsen/logrus"
@ -12,6 +14,114 @@ import (
"github.com/gorilla/context"
)
func getNextBuildVersion(startVersion string, currentVersion string) string {
re := regexp.MustCompile(`^(.*[^\d])?(\d+)([^\d].*)?$`)
m := re.FindStringSubmatch(startVersion)
if m == nil {
return startVersion
}
var prefix, suffix, body string
switch len(m) - 1 {
case 3:
prefix = m[1]
body = m[2]
suffix = m[3]
case 2:
if _, err := strconv.Atoi(m[1]); err == nil {
body = m[1]
suffix = m[2]
} else {
prefix = m[1]
body = m[2]
}
case 1:
body = m[1]
default:
return startVersion
}
if !strings.HasPrefix(currentVersion, prefix) ||
!strings.HasSuffix(currentVersion, suffix) {
return startVersion
}
curr, err := strconv.Atoi(currentVersion[len(prefix) : len(currentVersion)-len(suffix)])
if err != nil {
return startVersion
}
start, err := strconv.Atoi(body)
if err != nil {
panic(err)
}
var newVer int
if start > curr {
newVer = start
} else {
newVer = curr + 1
}
return prefix + strconv.Itoa(newVer) + suffix
}
func AddTaskToPool(d db.Store, taskObj db.Task, userID *int, projectID int) (newTask db.Task, err error) {
taskObj.Created = time.Now()
taskObj.Status = taskWaitingStatus
taskObj.UserID = userID
taskObj.ProjectID = projectID
tpl, err := d.GetTemplate(projectID, taskObj.TemplateID)
if err != nil {
return
}
err = taskObj.ValidateNewTask(tpl)
if err != nil {
return
}
if tpl.Type == db.TemplateBuild { // get next version for task if it is a Build
var builds []db.TaskWithTpl
builds, err = d.GetTemplateTasks(tpl, db.RetrieveQueryParams{Count: 1})
if err != nil {
return
}
if len(builds) == 0 {
taskObj.Version = tpl.StartVersion
} else {
v := getNextBuildVersion(*tpl.StartVersion, *builds[0].Version)
taskObj.Version = &v
}
}
newTask, err = d.CreateTask(taskObj)
if err != nil {
return
}
pool.register <- &task{
store: d,
task: newTask,
projectID: projectID,
}
objType := db.EventTask
desc := "Task ID " + strconv.Itoa(newTask.ID) + " queued for running"
_, err = d.CreateEvent(db.Event{
UserID: userID,
ProjectID: &projectID,
ObjectType: &objType,
ObjectID: &newTask.ID,
Description: &desc,
})
return
}
// AddTask inserts a task into the database and returns a header or returns error
func AddTask(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)
@ -23,33 +133,7 @@ func AddTask(w http.ResponseWriter, r *http.Request) {
return
}
taskObj.Created = time.Now()
taskObj.Status = taskWaitingStatus
taskObj.UserID = &user.ID
taskObj.ProjectID = project.ID
newTask, err := helpers.Store(r).CreateTask(taskObj)
if err != nil {
util.LogErrorWithFields(err, log.Fields{"error": "Bad request. Cannot create new task"})
w.WriteHeader(http.StatusBadRequest)
return
}
pool.register <- &task{
store: helpers.Store(r),
task: newTask,
projectID: project.ID,
}
objType := taskTypeID
desc := "Task ID " + strconv.Itoa(newTask.ID) + " queued for running"
_, err = helpers.Store(r).CreateEvent(db.Event{
UserID: &user.ID,
ProjectID: &project.ID,
ObjectType: &objType,
ObjectID: &newTask.ID,
Description: &desc,
})
newTask, err := AddTaskToPool(helpers.Store(r), taskObj, &user.ID, project.ID)
if err != nil {
util.LogErrorWithFields(err, log.Fields{"error": "Cannot write new event to database"})
@ -69,7 +153,7 @@ func GetTasksList(w http.ResponseWriter, r *http.Request, limit uint64) {
var tasks []db.TaskWithTpl
if tpl != nil {
tasks, err = helpers.Store(r).GetTemplateTasks(project.ID, tpl.(db.Template).ID, db.RetrieveQueryParams{
tasks, err = helpers.Store(r).GetTemplateTasks(tpl.(db.Template), db.RetrieveQueryParams{
Count: int(limit),
})
} else {
@ -94,12 +178,17 @@ func GetAllTasks(w http.ResponseWriter, r *http.Request) {
// GetLastTasks returns the hundred most recent tasks
func GetLastTasks(w http.ResponseWriter, r *http.Request) {
GetTasksList(w, r, 200)
str := r.URL.Query().Get("limit")
limit, err := strconv.Atoi(str)
if err != nil || limit <= 0 || limit > 200 {
limit = 200
}
GetTasksList(w, r, uint64(limit))
}
// GetTask returns a task based on its id
func GetTask(w http.ResponseWriter, r *http.Request) {
task := context.Get(r, taskTypeID).(db.Task)
task := context.Get(r, "task").(db.Task)
helpers.WriteJSON(w, http.StatusOK, task)
}
@ -122,14 +211,14 @@ func GetTaskMiddleware(next http.Handler) http.Handler {
return
}
context.Set(r, taskTypeID, task)
context.Set(r, "task", task)
next.ServeHTTP(w, r)
})
}
// GetTaskOutput returns the logged task output by id and writes it as json or returns error
func GetTaskOutput(w http.ResponseWriter, r *http.Request) {
task := context.Get(r, taskTypeID).(db.Task)
task := context.Get(r, "task").(db.Task)
project := context.Get(r, "project").(db.Project)
var output []db.TaskOutput
@ -183,7 +272,7 @@ func StopTask(w http.ResponseWriter, r *http.Request) {
// RemoveTask removes a task from the database
func RemoveTask(w http.ResponseWriter, r *http.Request) {
targetTask := context.Get(r, taskTypeID).(db.Task)
targetTask := context.Get(r, "task").(db.Task)
editor := context.Get(r, "user").(*db.User)
project := context.Get(r, "project").(db.Project)

32
api/tasks/http_test.go Normal file
View File

@ -0,0 +1,32 @@
package tasks
import (
"testing"
)
func TestGetNextBuildVersion(t *testing.T) {
s := getNextBuildVersion("new-1.4-patch", "new-1.5-patch")
if s != "new-1.6-patch" {
t.Fatal()
}
s = getNextBuildVersion("new-1.4", "new-1.5")
if s != "new-1.6" {
t.Fatal()
}
s = getNextBuildVersion("1.4-patch", "1.5-patch")
if s != "1.6-patch" {
t.Fatal()
}
s = getNextBuildVersion("1.4.8", "1.4.9")
if s != "1.4.10" {
t.Fatal()
}
s = getNextBuildVersion("0", "7")
if s != "8" {
t.Fatal()
}
}

View File

@ -1,27 +1,33 @@
package tasks
import (
"github.com/ansible-semaphore/semaphore/db"
"io/ioutil"
"strconv"
"github.com/ansible-semaphore/semaphore/util"
)
func (t *task) installInventory() error {
func (t *task) installInventory() (err error) {
if t.inventory.SSHKeyID != nil {
// write inventory key
err := t.installKey(t.inventory.SSHKey)
err = t.inventory.SSHKey.Install(db.AccessKeyUsageAnsibleUser)
if err != nil {
return err
return
}
}
switch t.inventory.Type {
case "static":
return t.installStaticInventory()
if t.inventory.BecomeKeyID != nil {
err = t.inventory.BecomeKey.Install(db.AccessKeyUsageAnsibleBecomeUser)
if err != nil {
return
}
}
return nil
if t.inventory.Type == db.InventoryStatic {
err = t.installStaticInventory()
}
return
}
func (t *task) installStaticInventory() error {

View File

@ -4,6 +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"
@ -11,15 +16,6 @@ import (
"strconv"
"strings"
"time"
"github.com/ansible-semaphore/semaphore/api/sockets"
"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 (
@ -29,7 +25,6 @@ const (
taskStoppedStatus = "stopped"
taskSuccessStatus = "success"
taskFailStatus = "error"
taskTypeID = "task"
gitURLFilePrefix = "file://"
)
@ -57,6 +52,12 @@ func (t *task) getRepoPath() string {
return util.Config.TmpPath + "/" + t.getRepoName()
}
func (t *task) validateRepo() error {
path := t.getRepoPath()
_, err := os.Stat(path)
return err
}
func (t *task) setStatus(status string) {
if t.task.Status == taskStoppingStatus {
switch status {
@ -67,19 +68,31 @@ func (t *task) setStatus(status string) {
panic("stopping task cannot be " + status)
}
}
t.task.Status = status
t.updateStatus()
if status == taskFailStatus {
t.sendMailAlert()
}
if status == taskSuccessStatus || status == taskFailStatus {
t.sendTelegramAlert()
}
}
func (t *task) updateStatus() {
for _, user := range t.users {
b, err := json.Marshal(&map[string]interface{}{
"type": "update",
"start": t.task.Start,
"end": t.task.End,
"status": t.task.Status,
"task_id": t.task.ID,
"project_id": t.projectID,
"type": "update",
"start": t.task.Start,
"end": t.task.End,
"status": t.task.Status,
"task_id": t.task.ID,
"template_id": t.task.TemplateID,
"project_id": t.projectID,
"version": t.task.Version,
})
util.LogPanic(err)
@ -94,23 +107,32 @@ func (t *task) updateStatus() {
func (t *task) fail() {
t.setStatus(taskFailStatus)
t.sendMailAlert()
t.sendTelegramAlert()
}
func (t *task) destroyKeys() {
err := t.destroyKey(t.repository.SSHKey)
if err != nil {
t.log("Can't destroy repository SSH key, error: " + err.Error())
t.log("Can't destroy repository key, error: " + err.Error())
}
err = t.destroyKey(t.inventory.SSHKey)
if err != nil {
t.log("Can't destroy inventory SSH key, error: " + err.Error())
t.log("Can't destroy inventory user key, error: " + err.Error())
}
err = t.destroyKey(t.inventory.BecomeKey)
if err != nil {
t.log("Can't destroy inventory become user key, error: " + err.Error())
}
err = t.destroyKey(t.template.VaultKey)
if err != nil {
t.log("Can't destroy inventory vault password file, error: " + err.Error())
}
}
func (t *task) createTaskEvent() {
objType := taskTypeID
objType := db.EventTask
desc := "Task ID " + strconv.Itoa(t.task.ID) + " (" + t.template.Alias + ")" + " finished - " + strings.ToUpper(t.task.Status)
_, err := t.store.CreateEvent(db.Event{
@ -152,7 +174,7 @@ func (t *task) prepareRun() {
return
}
objType := taskTypeID
objType := db.EventTask
desc := "Task ID " + strconv.Itoa(t.task.ID) + " (" + t.template.Alias + ")" + " is preparing"
_, err = t.store.CreateEvent(db.Event{
UserID: t.task.UserID,
@ -168,8 +190,10 @@ func (t *task) prepareRun() {
}
t.log("Prepare task with template: " + t.template.Alias + "\n")
t.updateStatus()
if err := t.installKey(t.repository.SSHKey); err != nil {
if err := t.installKey(t.repository.SSHKey, db.AccessKeyUsagePrivateKey); err != nil {
t.log("Failed installing ssh key for repository access: " + err.Error())
t.fail()
return
@ -188,6 +212,12 @@ func (t *task) prepareRun() {
}
}
if err := t.checkoutRepository(); err != nil {
t.log("Failed to checkout repository to required commit: " + err.Error())
t.fail()
return
}
if err := t.installInventory(); err != nil {
t.log("Failed to install inventory: " + err.Error())
t.fail()
@ -200,7 +230,7 @@ func (t *task) prepareRun() {
return
}
if err := t.installVaultPassFile(); err != nil {
if err := t.installVaultKeyFile(); err != nil {
t.log("Failed to install vault password file: " + err.Error())
t.fail()
return
@ -235,13 +265,11 @@ func (t *task) run() {
return
}
{
now := time.Now()
t.task.Start = &now
t.setStatus(taskRunningStatus)
}
now := time.Now()
t.task.Start = &now
t.setStatus(taskRunningStatus)
objType := taskTypeID
objType := db.EventTask
desc := "Task ID " + strconv.Itoa(t.task.ID) + " (" + t.template.Alias + ")" + " is running"
_, err := t.store.CreateEvent(db.Event{
@ -331,11 +359,6 @@ func (t *task) populateDetails() error {
return err
}
//if t.repository.SSHKey.Type != db.AccessKeySSH {
// t.log("Repository Access Key is not 'SSH': " + t.repository.SSHKey.Type)
// return errors.New("unsupported SSH Key")
//}
// get environment
if len(t.task.Environment) == 0 && t.template.EnvironmentID != nil {
t.environment, err = t.store.GetEnvironment(t.template.ProjectID, *t.template.EnvironmentID)
@ -357,17 +380,15 @@ func (t *task) destroyKey(key db.AccessKey) error {
return os.Remove(path)
}
func (t *task) installVaultPassFile() error {
if t.template.VaultPassID == nil {
func (t *task) installVaultKeyFile() error {
if t.template.VaultKeyID == nil {
return nil
}
path := t.template.VaultPass.GetPath()
return ioutil.WriteFile(path, []byte(t.template.VaultPass.LoginPassword.Password), 0600)
return t.template.VaultKey.Install(db.AccessKeyUsageVault)
}
func (t *task) installKey(key db.AccessKey) error {
func (t *task) installKey(key db.AccessKey, accessKeyUsage int) error {
if key.Type != db.AccessKeySSH {
return nil
}
@ -376,44 +397,117 @@ func (t *task) installKey(key db.AccessKey) error {
path := key.GetPath()
err := key.DeserializeSecret()
if err != nil {
return err
}
if key.SshKey.Passphrase != "" {
return fmt.Errorf("ssh key with passphrase not supported")
}
return ioutil.WriteFile(path, []byte(key.SshKey.PrivateKey), 0600)
return ioutil.WriteFile(path, []byte(key.SshKey.PrivateKey+"\n"), 0600)
}
func (t *task) checkoutRepository() error {
if t.task.CommitHash != nil { // checkout to commit if it is provided for task
err := t.validateRepo()
if err != nil {
return err
}
cmd := exec.Command("git")
cmd.Dir = t.getRepoPath()
t.log("Checkout repository to commit " + *t.task.CommitHash)
cmd.Args = append(cmd.Args, "checkout", *t.task.CommitHash)
t.logCmd(cmd)
return cmd.Run()
}
// store commit to task table
commitHash, err := t.getCommitHash()
if err != nil {
return err
}
commitMessage, _ := t.getCommitMessage()
t.task.CommitHash = &commitHash
t.task.CommitMessage = commitMessage
return t.store.UpdateTask(t.task)
}
// getCommitHash retrieves current commit hash from task repository
func (t *task) getCommitHash() (res string, err error) {
err = t.validateRepo()
if err != nil {
return
}
cmd := exec.Command("git")
cmd.Dir = t.getRepoPath()
t.log("Get current commit hash")
cmd.Args = append(cmd.Args, "rev-parse", "HEAD")
out, err := cmd.Output()
if err != nil {
return
}
res = strings.Trim(string(out), " \n")
return
}
// getCommitMessage retrieves current commit message from task repository
func (t *task) getCommitMessage() (res string, err error) {
err = t.validateRepo()
if err != nil {
return
}
cmd := exec.Command("git")
cmd.Dir = t.getRepoPath()
t.log("Get current commit message")
cmd.Args = append(cmd.Args, "show-branch", "--no-name", "HEAD")
out, err := cmd.Output()
if err != nil {
return
}
res = strings.Trim(string(out), " \n")
if len(res) > 100 {
res = res[0:100]
}
return
}
func (t *task) updateRepository() error {
t.getRepoPath()
repoName := t.getRepoName()
_, err := os.Stat(t.getRepoPath())
var gitSSHCommand string
if t.repository.SSHKey.Type == db.AccessKeySSH {
gitSSHCommand = t.repository.SSHKey.GetSshCommand()
}
cmd := exec.Command("git") //nolint: gas
cmd.Dir = util.Config.TmpPath
switch t.repository.SSHKey.Type {
case db.AccessKeySSH:
gitSSHCommand := "ssh -o StrictHostKeyChecking=no -i " + t.repository.SSHKey.GetPath()
cmd.Env = t.envVars(util.Config.TmpPath, util.Config.TmpPath, &gitSSHCommand)
case db.AccessKeyNone:
cmd.Env = t.envVars(util.Config.TmpPath, util.Config.TmpPath, nil)
default:
return fmt.Errorf("unsupported access key type: " + t.repository.SSHKey.Type)
}
t.setCmdEnvironment(cmd, gitSSHCommand)
repoURL, repoTag := t.repository.GitURL, "master"
if split := strings.Split(repoURL, "#"); len(split) > 1 {
repoURL, repoTag = split[0], split[1]
}
if err != nil && os.IsNotExist(err) {
err := t.validateRepo()
if err != nil {
if !os.IsNotExist(err) {
return err
}
t.log("Cloning repository " + repoURL)
cmd.Args = append(cmd.Args, "clone", "--recursive", "--branch", repoTag, repoURL, repoName)
} else if err != nil {
return err
cmd.Args = append(cmd.Args, "clone", "--recursive", "--branch", repoTag, repoURL, t.getRepoName())
} else {
t.log("Updating repository " + repoURL)
cmd.Dir += "/" + repoName
cmd.Dir = t.getRepoPath()
cmd.Args = append(cmd.Args, "pull", "origin", repoTag)
}
@ -453,8 +547,7 @@ func (t *task) runGalaxy(args []string) error {
cmd := exec.Command("ansible-galaxy", args...) //nolint: gas
cmd.Dir = t.getRepoPath()
gitSSHCommand := "ssh -o StrictHostKeyChecking=no -i " + t.repository.SSHKey.GetPath()
cmd.Env = t.envVars(util.Config.TmpPath, cmd.Dir, &gitSSHCommand)
t.setCmdEnvironment(cmd, t.repository.SSHKey.GetSshCommand())
t.logCmd(cmd)
return cmd.Run()
@ -474,7 +567,7 @@ func (t *task) listPlaybookHosts() (string, error) {
cmd := exec.Command("ansible-playbook", args...) //nolint: gas
cmd.Dir = t.getRepoPath()
cmd.Env = t.envVars(util.Config.TmpPath, cmd.Dir, nil)
t.setCmdEnvironment(cmd, "")
var errb bytes.Buffer
cmd.Stderr = &errb
@ -498,7 +591,7 @@ func (t *task) runPlaybook() (err error) {
}
cmd := exec.Command("ansible-playbook", args...) //nolint: gas
cmd.Dir = t.getRepoPath()
cmd.Env = t.envVars(util.Config.TmpPath, cmd.Dir, nil)
t.setCmdEnvironment(cmd, "")
t.logCmd(cmd)
cmd.Stdin = strings.NewReader("")
@ -511,61 +604,76 @@ func (t *task) runPlaybook() (err error) {
return
}
func (t *task) getExtraVars() (string, error) {
func (t *task) getExtraVars() (str string, err error) {
extraVars := make(map[string]interface{})
if t.inventory.SSHKey.Type == db.AccessKeyLoginPassword {
if t.inventory.SSHKey.LoginPassword.Login != "" {
extraVars["ansible_user"] = t.inventory.SSHKey.LoginPassword.Login
}
extraVars["ansible_password"] = t.inventory.SSHKey.LoginPassword.Password
}
if t.inventory.BecomeKey.Type == db.AccessKeyLoginPassword {
if t.inventory.SSHKey.LoginPassword.Login != "" {
extraVars["ansible_become_user"] = t.inventory.SSHKey.LoginPassword.Login
}
extraVars["ansible_become_password"] = t.inventory.SSHKey.LoginPassword.Password
}
if t.environment.JSON != "" {
err := json.Unmarshal([]byte(t.environment.JSON), &extraVars)
err = json.Unmarshal([]byte(t.environment.JSON), &extraVars)
if err != nil {
return "", err
return
}
}
delete(extraVars, "ENV")
ev, err := json.Marshal(extraVars)
if err != nil {
return "", err
if t.template.Type != db.TemplateTask &&
(util.Config.VariablesPassingMethod == util.VariablesPassingBoth ||
util.Config.VariablesPassingMethod == util.VariablesPassingExtra) {
extraVars["semaphore_task_type"] = t.template.Type
extraVars["semaphore_task_version"] = t.task.Version
}
return string(ev), nil
ev, err := json.Marshal(extraVars)
if err != nil {
return
}
str = string(ev)
return
}
//nolint: gocyclo
func (t *task) getPlaybookArgs() ([]string, error) {
func (t *task) getPlaybookArgs() (args []string, err error) {
playbookName := t.task.Playbook
if len(playbookName) == 0 {
if playbookName == "" {
playbookName = t.template.Playbook
}
var inventory string
switch t.inventory.Type {
case "file":
case db.InventoryFile:
inventory = t.inventory.Inventory
default:
inventory = util.Config.TmpPath + "/inventory_" + strconv.Itoa(t.task.ID)
}
args := []string{
args = []string{
"-i", inventory,
}
if t.inventory.SSHKeyID != nil && t.inventory.SSHKey.Type == db.AccessKeySSH {
args = append(args, "--private-key="+t.inventory.SSHKey.GetPath())
if t.inventory.SSHKeyID != nil {
switch t.inventory.SSHKey.Type {
case db.AccessKeySSH:
args = append(args, "--private-key="+t.inventory.SSHKey.GetPath())
case db.AccessKeyLoginPassword:
args = append(args, "--extra-vars=@"+t.inventory.SSHKey.GetPath())
case db.AccessKeyNone:
default:
err = fmt.Errorf("access key does not suite for inventory's User Access Key")
return
}
}
if t.inventory.BecomeKeyID != nil {
switch t.inventory.BecomeKey.Type {
case db.AccessKeyLoginPassword:
args = append(args, "--extra-vars=@"+t.inventory.BecomeKey.GetPath())
case db.AccessKeyNone:
default:
err = fmt.Errorf("access key does not suite for inventory's Become User Access Key")
return
}
}
if t.task.Debug {
@ -576,8 +684,8 @@ func (t *task) getPlaybookArgs() ([]string, error) {
args = append(args, "--check")
}
if t.template.VaultPassID != nil {
args = append(args, "--vault-password-file", t.template.VaultPass.GetPath())
if t.template.VaultKeyID != nil {
args = append(args, "--vault-password-file", t.template.VaultKey.GetPath())
}
extraVars, err := t.getExtraVars()
@ -590,19 +698,10 @@ func (t *task) getPlaybookArgs() ([]string, error) {
var templateExtraArgs []string
if t.template.Arguments != nil {
err := json.Unmarshal([]byte(*t.template.Arguments), &templateExtraArgs)
err = json.Unmarshal([]byte(*t.template.Arguments), &templateExtraArgs)
if err != nil {
t.log("Could not unmarshal arguments to []string")
return nil, err
}
}
var taskExtraArgs []string
if t.task.Arguments != nil {
err := json.Unmarshal([]byte(*t.task.Arguments), &taskExtraArgs)
if err != nil {
t.log("Could not unmarshal arguments to []string")
return nil, err
return
}
}
@ -610,25 +709,41 @@ func (t *task) getPlaybookArgs() ([]string, error) {
args = templateExtraArgs
} else {
args = append(args, templateExtraArgs...)
args = append(args, taskExtraArgs...)
args = append(args, playbookName)
}
return args, nil
return
}
func (t *task) envVars(home string, pwd string, gitSSHCommand *string) []string {
func (t *task) setCmdEnvironment(cmd *exec.Cmd, gitSSHCommand string) {
env := os.Environ()
env = append(env, fmt.Sprintf("HOME=%s", home))
env = append(env, fmt.Sprintf("PWD=%s", pwd))
env = append(env, fmt.Sprintf("HOME=%s", util.Config.TmpPath))
env = append(env, fmt.Sprintf("PWD=%s", cmd.Dir))
env = append(env, fmt.Sprintln("PYTHONUNBUFFERED=1"))
//env = append(env, fmt.Sprintln("GIT_FLUSH=1"))
env = append(env, extractCommandEnvironment(t.environment.JSON)...)
if gitSSHCommand != nil {
env = append(env, fmt.Sprintf("GIT_SSH_COMMAND=%s", *gitSSHCommand))
if t.template.Type != db.TemplateTask &&
(util.Config.VariablesPassingMethod == util.VariablesPassingBoth ||
util.Config.VariablesPassingMethod == util.VariablesPassingEnv) {
env = append(env, "SEMAPHORE_TASK_TYPE="+string(t.template.Type))
var version string
switch t.template.Type {
case db.TemplateBuild:
version = *t.task.Version
case db.TemplateDeploy:
buildTask, err := t.store.GetTask(t.task.ProjectID, *t.task.BuildTaskID)
if err != nil {
panic("Deploy task has no build task")
}
version = *buildTask.Version
}
env = append(env, "SEMAPHORE_TASK_VERSION="+version)
}
return env
if gitSSHCommand != "" {
env = append(env, fmt.Sprintf("GIT_SSH_COMMAND=%s", gitSSHCommand))
}
cmd.Env = env
}
func hasRequirementsChanges(requirementsFilePath string, requirementsHashFilePath string) bool {

View File

@ -1,12 +1,122 @@
package tasks
import (
"testing"
"github.com/ansible-semaphore/semaphore/db"
"github.com/ansible-semaphore/semaphore/util"
"math/rand"
"time"
"os"
"strings"
"testing"
"time"
)
func TestTaskGetPlaybookArgs(t *testing.T) {
util.Config = &util.ConfigType{
TmpPath: "/tmp",
}
inventoryID := 1
tsk := task{
task: db.Task{},
inventory: db.Inventory{
SSHKeyID: &inventoryID,
SSHKey: db.AccessKey{
ID: 12345,
Type: db.AccessKeySSH,
},
},
template: db.Template{
Playbook: "test.yml",
},
}
args, err := tsk.getPlaybookArgs()
if err != nil {
t.Fatal(err)
}
res := strings.Join(args, " ")
if res != "-i /tmp/inventory_0 --private-key=/tmp/access_key_12345 --extra-vars {} test.yml" {
t.Fatal("incorrect result")
}
}
func TestTaskGetPlaybookArgs2(t *testing.T) {
util.Config = &util.ConfigType{
TmpPath: "/tmp",
}
inventoryID := 1
tsk := task{
task: db.Task{},
inventory: db.Inventory{
SSHKeyID: &inventoryID,
SSHKey: db.AccessKey{
ID: 12345,
Type: db.AccessKeyLoginPassword,
LoginPassword: db.LoginPassword{
Password: "123456",
Login: "root",
},
},
},
template: db.Template{
Playbook: "test.yml",
},
}
args, err := tsk.getPlaybookArgs()
if err != nil {
t.Fatal(err)
}
res := strings.Join(args, " ")
if res != "-i /tmp/inventory_0 --extra-vars=@/tmp/access_key_12345 --extra-vars {} test.yml" {
t.Fatal("incorrect result")
}
}
func TestTaskGetPlaybookArgs3(t *testing.T) {
util.Config = &util.ConfigType{
TmpPath: "/tmp",
}
inventoryID := 1
tsk := task{
task: db.Task{},
inventory: db.Inventory{
BecomeKeyID: &inventoryID,
BecomeKey: db.AccessKey{
ID: 12345,
Type: db.AccessKeyLoginPassword,
LoginPassword: db.LoginPassword{
Password: "123456",
Login: "root",
},
},
},
template: db.Template{
Playbook: "test.yml",
},
}
args, err := tsk.getPlaybookArgs()
if err != nil {
t.Fatal(err)
}
res := strings.Join(args, " ")
if res != "-i /tmp/inventory_0 --extra-vars=@/tmp/access_key_12345 --extra-vars {} test.yml" {
t.Fatal("incorrect result")
}
}
func TestCheckTmpDir(t *testing.T) {
//It should be able to create a random dir in /tmp

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"
@ -12,6 +13,7 @@ import (
"github.com/gorilla/context"
"github.com/gorilla/handlers"
"github.com/spf13/cobra"
"go.etcd.io/bbolt"
"net/http"
"os"
)
@ -51,7 +53,10 @@ func Execute() {
func runService() {
store := createStore()
schedulePool := schedules.CreateSchedulePool(store)
defer store.Close()
defer schedulePool.Destroy()
dialect, err := util.Config.GetDialect()
if err != nil {
@ -74,12 +79,14 @@ func runService() {
go sockets.StartWS()
go tasks.StartRunner()
go schedulePool.Run()
route := api.Route()
route.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
context.Set(r, "store", store)
context.Set(r, "schedule_pool", schedulePool)
next.ServeHTTP(w, r)
})
})
@ -104,7 +111,12 @@ func createStore() db.Store {
store := factory.CreateStore()
if err := store.Connect(); err != nil {
fmt.Println("\n Have you run `semaphore setup`?")
switch err {
case bbolt.ErrTimeout:
fmt.Println("\n [ERR_BOLTDB_TIMEOUT] BoltDB supports only one connection at a time. You should stop service when using CLI.")
default:
fmt.Println("\n Have you run `semaphore setup`?")
}
panic(err)
}

View File

@ -7,8 +7,8 @@ import (
)
func init() {
userGetCmd.PersistentFlags().StringVar(&targetUserArgs.login, "login", "", "Login of the user you want to delete")
userGetCmd.PersistentFlags().StringVar(&targetUserArgs.email, "email", "", "Email of the user you want to delete")
userGetCmd.PersistentFlags().StringVar(&targetUserArgs.login, "login", "", "Login of the user you want to see")
userGetCmd.PersistentFlags().StringVar(&targetUserArgs.email, "email", "", "Email of the user you want to see")
userCmd.AddCommand(userGetCmd)
}

View File

@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"strconv"
"github.com/ansible-semaphore/semaphore/util"
@ -36,7 +37,7 @@ type AccessKey struct {
LoginPassword LoginPassword `db:"-" json:"login_password"`
SshKey SshKey `db:"-" json:"ssh"`
OverrideSecret bool `db:"-" json:"override_secret"`
OverrideSecret bool `db:"-" json:"override_secret"`
}
type LoginPassword struct {
@ -45,15 +46,102 @@ type LoginPassword struct {
}
type SshKey struct {
Login string `json:"login"`
Passphrase string `json:"passphrase"`
PrivateKey string `json:"private_key"`
}
type AccessKeyUsage int
const (
AccessKeyUsageAnsibleUser = iota
AccessKeyUsageAnsibleBecomeUser
AccessKeyUsagePrivateKey
AccessKeyUsageVault
)
func (key AccessKey) Install(usage AccessKeyUsage) error {
if key.Type == AccessKeyNone {
return nil
}
path := key.GetPath()
err := key.DeserializeSecret()
if err != nil {
return err
}
switch usage {
case AccessKeyUsagePrivateKey:
if key.SshKey.Passphrase != "" {
return fmt.Errorf("ssh key with passphrase not supported")
}
return ioutil.WriteFile(path, []byte(key.SshKey.PrivateKey + "\n"), 0600)
case AccessKeyUsageVault:
switch key.Type {
case AccessKeyLoginPassword:
return ioutil.WriteFile(path, []byte(key.LoginPassword.Password), 0600)
}
case AccessKeyUsageAnsibleBecomeUser:
switch key.Type {
case AccessKeyLoginPassword:
content := make(map[string]string)
content["ansible_become_user"] = key.LoginPassword.Login
content["ansible_become_password"] = key.LoginPassword.Password
var bytes []byte
bytes, err = json.Marshal(content)
if err != nil {
return err
}
return ioutil.WriteFile(path, bytes, 0600)
default:
return fmt.Errorf("access key type not supported for ansible user")
}
case AccessKeyUsageAnsibleUser:
switch key.Type {
case AccessKeySSH:
if key.SshKey.Passphrase != "" {
return fmt.Errorf("ssh key with passphrase not supported")
}
return ioutil.WriteFile(path, []byte(key.SshKey.PrivateKey + "\n"), 0600)
case AccessKeyLoginPassword:
content := make(map[string]string)
content["ansible_user"] = key.LoginPassword.Login
content["ansible_password"] = key.LoginPassword.Password
var bytes []byte
bytes, err = json.Marshal(content)
if err != nil {
return err
}
return ioutil.WriteFile(path, bytes, 0600)
default:
return fmt.Errorf("access key type not supported for ansible user")
}
}
return nil
}
// GetPath returns the location of the access key once written to disk
func (key AccessKey) GetPath() string {
return util.Config.TmpPath + "/access_key_" + strconv.Itoa(key.ID)
}
func (key AccessKey) GetSshCommand() string {
if key.Type != AccessKeySSH {
panic("type must be ssh")
}
args := "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i " + key.GetPath()
if util.Config.SshConfigPath != "" {
args += " -F " + util.Config.SshConfigPath
}
return args
}
func (key AccessKey) Validate(validateSecretFields bool) error {
if key.Name == "" {
return fmt.Errorf("name can not be empty")
@ -97,13 +185,13 @@ func (key *AccessKey) SerializeSecret() error {
return nil
}
if util.Config.CookieEncryption == "" {
if util.Config.AccessKeyEncryption == "" {
secret := base64.StdEncoding.EncodeToString(plaintext)
key.Secret = &secret
return nil
}
encryption, err := base64.StdEncoding.DecodeString(util.Config.CookieEncryption)
encryption, err := base64.StdEncoding.DecodeString(util.Config.AccessKeyEncryption)
if err != nil {
return err
@ -149,7 +237,7 @@ func (key *AccessKey) unmarshalAppropriateField(secret []byte) (err error) {
}
func (key *AccessKey) ResetSecret() {
key.Secret = nil
//key.Secret = nil
key.LoginPassword = LoginPassword{}
key.SshKey = SshKey{}
}
@ -177,10 +265,14 @@ func (key *AccessKey) DeserializeSecret() error {
}
if util.Config.AccessKeyEncryption == "" {
return key.unmarshalAppropriateField(ciphertext)
err = key.unmarshalAppropriateField(ciphertext)
if _, ok := err.(*json.SyntaxError); ok {
err = fmt.Errorf("[ERR_INVALID_ENCRYPTION_KEY] Cannot decrypt access key, perhaps encryption key was changed")
}
return err
}
encryption, err := base64.StdEncoding.DecodeString(util.Config.CookieEncryption)
encryption, err := base64.StdEncoding.DecodeString(util.Config.AccessKeyEncryption)
if err != nil {
return err
}
@ -205,6 +297,9 @@ func (key *AccessKey) DeserializeSecret() error {
ciphertext, err = gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
if err.Error() == "cipher: message authentication failed" {
err = fmt.Errorf("[ERR_INVALID_ENCRYPTION_KEY] Cannot decrypt access key, perhaps encryption key was changed")
}
return err
}

View File

@ -6,6 +6,32 @@ import (
"testing"
)
func TestSetSecret(t *testing.T) {
accessKey := AccessKey{
Type: AccessKeySSH,
SshKey: SshKey{
PrivateKey: "qerphqeruqoweurqwerqqeuiqwpavqr",
},
}
util.Config = &util.ConfigType{}
err := accessKey.SerializeSecret()
if err != nil {
t.Error(err)
}
secret, err := base64.StdEncoding.DecodeString(*accessKey.Secret)
if err != nil {
t.Error(err)
}
if string(secret) != "{\"login\":\"\",\"passphrase\":\"\",\"private_key\":\"qerphqeruqoweurqwerqqeuiqwpavqr\"}" {
t.Error("invalid secret")
}
}
func TestGetSecret(t *testing.T) {
secret := base64.StdEncoding.EncodeToString([]byte(`{
"passphrase": "123456",
@ -15,7 +41,7 @@ func TestGetSecret(t *testing.T) {
accessKey := AccessKey{
Secret: &secret,
Type: AccessKeySSH,
Type: AccessKeySSH,
}
err := accessKey.DeserializeSecret()
@ -32,3 +58,34 @@ func TestGetSecret(t *testing.T) {
t.Errorf("")
}
}
func TestSetGetSecretWithEncryption(t *testing.T) {
accessKey := AccessKey{
Type: AccessKeySSH,
SshKey: SshKey{
PrivateKey: "qerphqeruqoweurqwerqqeuiqwpavqr",
},
}
util.Config = &util.ConfigType{
AccessKeyEncryption: "hHYgPrhQTZYm7UFTvcdNfKJMB3wtAXtJENUButH+DmM=",
}
err := accessKey.SerializeSecret()
if err != nil {
t.Error(err)
}
accessKey.ResetSecret()
err = accessKey.DeserializeSecret()
if err != nil {
t.Error(err)
}
if accessKey.SshKey.PrivateKey != "qerphqeruqoweurqwerqqeuiqwpavqr" {
t.Error("invalid secret")
}
}

View File

@ -6,14 +6,102 @@ import (
// Event represents information generated by ansible or api action captured to the database during execution
type Event struct {
UserID *int `db:"user_id" json:"user_id"`
ProjectID *int `db:"project_id" json:"project_id"`
ObjectID *int `db:"object_id" json:"object_id"`
ObjectType *string `db:"object_type" json:"object_type"`
Description *string `db:"description" json:"description"`
Created time.Time `db:"created" json:"created"`
UserID *int `db:"user_id" json:"user_id"`
ProjectID *int `db:"project_id" json:"project_id"`
ObjectID *int `db:"object_id" json:"object_id"`
ObjectType *EventObjectType `db:"object_type" json:"object_type"`
Description *string `db:"description" json:"description"`
Created time.Time `db:"created" json:"created"`
ObjectName string `db:"-" json:"object_name"`
ProjectName *string `db:"project_name" json:"project_name"`
Username *string `db:"-" json:"username"`
}
type EventObjectType string
const (
EventTask EventObjectType = "task"
EventEnvironment EventObjectType = "environment"
EventInventory EventObjectType = "inventory"
EventKey EventObjectType = "key"
EventProject EventObjectType = "project"
EventRepository EventObjectType = "repository"
EventSchedule EventObjectType = "schedule"
EventTemplate EventObjectType = "template"
EventUser EventObjectType = "user"
EventView EventObjectType = "view"
)
func FillEvents(d Store, events []Event) (err error) {
usernames := make(map[int]string)
for i, evt := range events {
var objName string
objName, err = getEventObjectName(d, evt)
if err != nil {
return
}
if objName != "" {
events[i].ObjectName = objName
}
if evt.UserID == nil {
continue
}
var username string
username, ok := usernames[*evt.UserID]
if !ok {
username, err = getEventUsername(d, evt)
if err != nil {
return
}
if username == "" {
continue
}
usernames[*evt.UserID] = username
}
events[i].Username = &username
}
return
}
func getEventObjectName(d Store, evt Event) (string, error) {
if evt.ObjectID == nil || evt.ObjectType == nil {
return "", nil
}
switch *evt.ObjectType {
case EventTask:
task, err := d.GetTask(*evt.ProjectID, *evt.ObjectID)
if err != nil {
return "", err
}
return task.Playbook, nil
default:
return "", nil
}
}
func getEventUsername(d Store, evt Event) (username string, err error) {
if evt.UserID == nil {
return "", nil
}
user, err := d.GetUser(*evt.UserID)
if err != nil {
return "", err
}
return user.Username, nil
}

View File

@ -1,5 +1,9 @@
package db
const (
InventoryStatic = "static"
InventoryFile = "file"
)
// Inventory is the model of an ansible inventory file
type Inventory struct {
ID int `db:"id" json:"id"`
@ -19,3 +23,19 @@ type Inventory struct {
Removed bool `db:"removed" json:"removed"`
}
func FillInventory(d Store, inventory *Inventory) (err error) {
if inventory.SSHKeyID != nil {
inventory.SSHKey, err = d.GetAccessKey(inventory.ProjectID, *inventory.SSHKeyID)
}
if err != nil {
return
}
if inventory.BecomeKeyID != nil {
inventory.BecomeKey, err = d.GetAccessKey(inventory.ProjectID, *inventory.BecomeKeyID)
}
return
}

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

@ -3,6 +3,7 @@ package db
import (
"errors"
log "github.com/Sirupsen/logrus"
"reflect"
"time"
)
@ -25,24 +26,23 @@ type RetrieveQueryParams struct {
SortInverted bool
}
type ObjectScope int
// ObjectProperties describe database entities.
// It mainly used for NoSQL implementations (currently BoltDB) to preserve same
// data structure of different implementations and easy change it if required.
type ObjectProperties struct {
TableName string
IsGlobal bool // doesn't belong to other table, for example to project or user.
ForeignColumnName string
PrimaryColumnName string
SortableColumns []string
SortInverted bool
TableName string
IsGlobal bool // doesn't belong to other table, for example to project or user.
ForeignColumnSuffix string
PrimaryColumnName string
SortableColumns []string
DefaultSortingColumn string
SortInverted bool // sort from high to low object ID by default. It is useful for some NoSQL implementations.
Type reflect.Type // to which type the table bust be mapped.
}
var ErrNotFound = errors.New("no rows in result set")
var ErrInvalidOperation = errors.New("invalid operation")
func ValidateUsername(login string) error {
return nil
}
type Store interface {
Connect() error
Close() error
@ -71,18 +71,12 @@ type Store interface {
GetAccessKey(projectID int, accessKeyID int) (AccessKey, error)
GetAccessKeys(projectID int, params RetrieveQueryParams) ([]AccessKey, error)
UpdateAccessKey(accessKey AccessKey) error
CreateAccessKey(accessKey AccessKey) (AccessKey, error)
DeleteAccessKey(projectID int, accessKeyID int) error
DeleteAccessKeySoft(projectID int, accessKeyID int) error
GetGlobalAccessKey(accessKeyID int) (AccessKey, error)
GetGlobalAccessKeys(params RetrieveQueryParams) ([]AccessKey, error)
UpdateGlobalAccessKey(accessKey AccessKey) error
CreateGlobalAccessKey(accessKey AccessKey) (AccessKey, error)
DeleteGlobalAccessKey(accessKeyID int) error
DeleteGlobalAccessKeySoft(accessKeyID int) error
GetUsers(params RetrieveQueryParams) ([]User, error)
CreateUserWithoutPassword(user User) (User, error)
CreateUser(user UserWithPwd) (User, error)
@ -107,6 +101,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
@ -130,170 +131,96 @@ type Store interface {
CreateTask(task Task) (Task, error)
UpdateTask(task Task) error
GetTemplateTasks(projectID int, templateID int, params RetrieveQueryParams) ([]TaskWithTpl, error)
GetTemplateTasks(template Template, params RetrieveQueryParams) ([]TaskWithTpl, error)
GetProjectTasks(projectID int, params RetrieveQueryParams) ([]TaskWithTpl, error)
GetTask(projectID int, taskID int) (Task, error)
DeleteTaskWithOutputs(projectID int, taskID int) error
GetTaskOutputs(projectID int, taskID int) ([]TaskOutput, error)
CreateTaskOutput(output TaskOutput) (TaskOutput, error)
}
func FillTemplate(d Store, template *Template) (err error) {
if template.VaultPassID != nil {
template.VaultPass, err = d.GetAccessKey(template.ProjectID, *template.VaultPassID)
}
return
}
func FillInventory(d Store, inventory *Inventory) (err error) {
if inventory.SSHKeyID != nil {
inventory.SSHKey, err = d.GetAccessKey(inventory.ProjectID, *inventory.SSHKeyID)
}
if err != nil {
return
}
if inventory.BecomeKeyID != nil {
inventory.BecomeKey, err = d.GetAccessKey(inventory.ProjectID, *inventory.BecomeKeyID)
}
return
}
func FillEvents(d Store, events []Event) (err error) {
usernames := make(map[int]string)
for i, evt := range events {
var objName string
objName, err = getEventObjectName(d, evt)
if err != nil {
return
}
if objName != "" {
events[i].ObjectName = objName
}
if evt.UserID == nil {
continue
}
var username string
username, ok := usernames[*evt.UserID]
if !ok {
username, err = getEventUsername(d, evt)
if err != nil {
return
}
if username == "" {
continue
}
usernames[*evt.UserID] = username
}
events[i].Username = &username
}
return
}
func getEventObjectName(d Store, evt Event) (string, error) {
if evt.ObjectID == nil || evt.ObjectType == nil {
return "", nil
}
switch *evt.ObjectType {
case "task":
task, err := d.GetTask(*evt.ProjectID, *evt.ObjectID)
if err != nil {
return "", err
}
return task.Playbook, nil
default:
return "", nil
}
}
func getEventUsername(d Store, evt Event) (username string, err error) {
if evt.UserID == nil {
return "", nil
}
user, err := d.GetUser(*evt.UserID)
if err != nil {
return "", err
}
return user.Username, nil
GetView(projectID int, viewID int) (View, error)
GetViews(projectID int) ([]View, error)
GetViewTemplates(projectID int, viewID int, params RetrieveQueryParams) ([]Template, error)
UpdateView(view View) error
CreateView(view View) (View, error)
DeleteView(projectID int, viewID int) error
SetViewPositions(projectID int, viewPositions map[int]int) error
}
var AccessKeyProps = ObjectProperties{
TableName: "access_key",
SortableColumns: []string{"name", "type"},
PrimaryColumnName: "id",
}
var GlobalAccessKeyProps = ObjectProperties{
IsGlobal: true,
TableName: "access_key",
SortableColumns: []string{"name", "type"},
ForeignColumnName: "ssh_key_id",
PrimaryColumnName: "id",
TableName: "access_key",
SortableColumns: []string{"name", "type"},
ForeignColumnSuffix: "key_id",
PrimaryColumnName: "id",
Type: reflect.TypeOf(AccessKey{}),
DefaultSortingColumn: "name",
}
var EnvironmentProps = ObjectProperties{
TableName: "project__environment",
SortableColumns: []string{"name"},
ForeignColumnName: "environment_id",
PrimaryColumnName: "id",
TableName: "project__environment",
SortableColumns: []string{"name"},
ForeignColumnSuffix: "environment_id",
PrimaryColumnName: "id",
Type: reflect.TypeOf(Environment{}),
DefaultSortingColumn: "name",
}
var InventoryProps = ObjectProperties{
TableName: "project__inventory",
SortableColumns: []string{"name"},
ForeignColumnName: "inventory_id",
PrimaryColumnName: "id",
TableName: "project__inventory",
SortableColumns: []string{"name"},
ForeignColumnSuffix: "inventory_id",
PrimaryColumnName: "id",
Type: reflect.TypeOf(Inventory{}),
DefaultSortingColumn: "name",
}
var RepositoryProps = ObjectProperties{
TableName: "project__repository",
ForeignColumnName: "repository_id",
PrimaryColumnName: "id",
TableName: "project__repository",
ForeignColumnSuffix: "repository_id",
PrimaryColumnName: "id",
Type: reflect.TypeOf(Repository{}),
DefaultSortingColumn: "name",
}
var TemplateProps = ObjectProperties{
TableName: "project__template",
SortableColumns: []string{"name"},
TableName: "project__template",
SortableColumns: []string{"name"},
PrimaryColumnName: "id",
Type: reflect.TypeOf(Template{}),
DefaultSortingColumn: "alias",
}
var ScheduleProps = ObjectProperties{
TableName: "project__schedule",
PrimaryColumnName: "id",
Type: reflect.TypeOf(Schedule{}),
}
var ProjectUserProps = ObjectProperties{
TableName: "project__user",
PrimaryColumnName: "user_id",
Type: reflect.TypeOf(ProjectUser{}),
}
var ProjectProps = ObjectProperties{
TableName: "project",
IsGlobal: true,
PrimaryColumnName: "id",
TableName: "project",
IsGlobal: true,
PrimaryColumnName: "id",
Type: reflect.TypeOf(Project{}),
DefaultSortingColumn: "name",
}
var UserProps = ObjectProperties{
TableName: "user",
IsGlobal: true,
PrimaryColumnName: "id",
Type: reflect.TypeOf(User{}),
}
var SessionProps = ObjectProperties{
TableName: "session",
PrimaryColumnName: "id",
Type: reflect.TypeOf(Session{}),
}
var TokenProps = ObjectProperties{
@ -306,9 +233,17 @@ var TaskProps = ObjectProperties{
IsGlobal: true,
PrimaryColumnName: "id",
SortInverted: true,
Type: reflect.TypeOf(Task{}),
}
var TaskOutputProps = ObjectProperties{
TableName: "task__output",
PrimaryColumnName: "",
TableName: "task__output",
Type: reflect.TypeOf(TaskOutput{}),
}
var ViewProps = ObjectProperties{
TableName: "project__view",
PrimaryColumnName: "id",
Type: reflect.TypeOf(View{}),
DefaultSortingColumn: "position",
}

View File

@ -1,6 +1,8 @@
package db
import "time"
import (
"time"
)
//Task is a model of a task which will be executed by the runner
type Task struct {
@ -16,22 +18,52 @@ type Task struct {
// override variables
Playbook string `db:"playbook" json:"playbook"`
Environment string `db:"environment" json:"environment"`
// to fit into []string
Arguments *string `db:"arguments" json:"arguments"`
UserID *int `db:"user_id" json:"user_id"`
Created time.Time `db:"created" json:"created"`
Start *time.Time `db:"start" json:"start"`
End *time.Time `db:"end" json:"end"`
Message string `db:"message" json:"message"`
CommitHash *string `db:"commit_hash" json:"commit_hash"`
// CommitMessage contains message retrieved from git repository after checkout to CommitHash.
// It is readonly by API.
CommitMessage string `db:"commit_message" json:"commit_message"`
BuildTaskID *int `db:"build_task_id" json:"build_task_id"`
Version *string `db:"version" json:"version"`
}
func (task *Task) ValidateNewTask(template Template) error {
switch template.Type {
case TemplateBuild:
case TemplateDeploy:
case TemplateTask:
}
return nil
}
func (task *TaskWithTpl) Fill(d Store) error {
if task.BuildTaskID != nil {
build, err := d.GetTask(task.ProjectID, *task.BuildTaskID)
if err != nil {
return err
}
task.BuildTask = &build
}
return nil
}
// TaskWithTpl is the task data with additional fields
type TaskWithTpl struct {
Task
TemplatePlaybook string `db:"tpl_playbook" json:"tpl_playbook"`
TemplateAlias string `db:"tpl_alias" json:"tpl_alias"`
UserName *string `db:"user_name" json:"user_name"`
TemplatePlaybook string `db:"tpl_playbook" json:"tpl_playbook"`
TemplateAlias string `db:"tpl_alias" json:"tpl_alias"`
TemplateType TemplateType `db:"tpl_type" json:"tpl_type"`
UserName *string `db:"user_name" json:"user_name"`
BuildTask *Task `db:"-" json:"build_task"`
}
// TaskOutput is the ansible log output from the task

View File

@ -1,5 +1,13 @@
package db
type TemplateType string
const (
TemplateTask TemplateType = ""
TemplateBuild TemplateType = "build"
TemplateDeploy TemplateType = "deploy"
)
// Template is a user defined model that is used to run a task
type Template struct {
ID int `db:"id" json:"id"`
@ -22,6 +30,44 @@ type Template struct {
Description *string `db:"description" json:"description"`
VaultPassID *int `db:"vault_pass_id" json:"vault_pass_id"`
VaultPass AccessKey `db:"-" json:"-"`
VaultKeyID *int `db:"vault_key_id" json:"vault_key_id"`
VaultKey AccessKey `db:"-" json:"-"`
Type TemplateType `db:"type" json:"type"`
StartVersion *string `db:"start_version" json:"start_version"`
BuildTemplateID *int `db:"build_template_id" json:"build_template_id"`
ViewID *int `db:"view_id" json:"view_id"`
LastTask *TaskWithTpl `db:"-" json:"last_task"`
}
func FillTemplates(d Store, templates []Template) (err error) {
for i := range templates {
tpl := &templates[i]
var tasks []TaskWithTpl
tasks, err = d.GetTemplateTasks(*tpl, RetrieveQueryParams{Count: 1})
if err != nil {
return
}
if len(tasks) > 0 {
tpl.LastTask = &tasks[0]
}
}
return
}
func FillTemplate(d Store, template *Template) (err error) {
if template.VaultKeyID != nil {
template.VaultKey, err = d.GetAccessKey(template.ProjectID, *template.VaultKeyID)
}
if err != nil {
return
}
err = FillTemplates(d, []Template{*template})
return
}

View File

@ -22,3 +22,8 @@ type UserWithPwd struct {
Pwd string `db:"-" json:"password"` // unhashed password from JSON
User
}
func ValidateUsername(login string) error {
return nil
}

17
db/View.go Normal file
View File

@ -0,0 +1,17 @@
package db
import "fmt"
type View struct {
ID int `db:"id" json:"id"`
ProjectID int `db:"project_id" json:"project_id"`
Title string `db:"title" json:"title"`
Position int `db:"position" json:"position"`
}
func (view *View) Validate() error {
if view.Title == "" {
return fmt.Errorf("title can not be empty")
}
return nil
}

View File

@ -9,6 +9,8 @@ import (
"go.etcd.io/bbolt"
"reflect"
"sort"
"strings"
"time"
)
const MaxID = 2147483647
@ -79,7 +81,10 @@ func (d *BoltDb) Connect() error {
}
var err error
d.db, err = bbolt.Open(filename, 0666, nil)
d.db, err = bbolt.Open(filename, 0666, &bbolt.Options{
Timeout: 5 * time.Second,
})
if err != nil {
return err
}
@ -109,12 +114,12 @@ func (d *BoltDb) getObject(bucketID int, props db.ObjectProperties, objectID obj
return
}
// getFieldNameByTag tries to find field by tag name and value in provided type.
// getFieldNameByTagSuffix tries to find field by tag name and value in provided type.
// It returns error if field not found.
func getFieldNameByTag(t reflect.Type, tagName string, tagValue string) (string, error) {
func getFieldNameByTagSuffix(t reflect.Type, tagName string, tagValueSuffix string) (string, error) {
n := t.NumField()
for i := 0; i < n; i++ {
if t.Field(i).Tag.Get(tagName) == tagValue {
if strings.HasSuffix(t.Field(i).Tag.Get(tagName), tagValueSuffix) {
return t.Field(i).Name, nil
}
}
@ -122,7 +127,7 @@ func getFieldNameByTag(t reflect.Type, tagName string, tagValue string) (string,
if t.Field(i).Tag != "" || t.Field(i).Type.Kind() != reflect.Struct {
continue
}
str, err := getFieldNameByTag(t.Field(i).Type, tagName, tagValue)
str, err := getFieldNameByTagSuffix(t.Field(i).Type, tagName, tagValueSuffix)
if err == nil {
return str, nil
}
@ -134,7 +139,7 @@ func sortObjects(objects interface{}, sortBy string, sortInverted bool) error {
objectsValue := reflect.ValueOf(objects).Elem()
objType := objectsValue.Type().Elem()
fieldName, err := getFieldNameByTag(objType, "db", sortBy)
fieldName, err := getFieldNameByTagSuffix(objType, "db", sortBy)
if err != nil {
return err
}
@ -315,63 +320,68 @@ func (d *BoltDb) getObjects(bucketID int, props db.ObjectProperties, params db.R
})
}
func (d *BoltDb) isObjectInUse(bucketID int, props db.ObjectProperties, objID objectID, userProps db.ObjectProperties) (inUse bool, err error) {
var templates []db.Template
func isObjectBelongTo(props db.ObjectProperties, objID objectID, tpl interface{}) bool {
if props.ForeignColumnSuffix == "" {
return false
}
err = d.getObjects(bucketID, userProps, db.RetrieveQueryParams{}, func (tpl interface{}) bool {
if props.ForeignColumnName == "" {
fieldName, err := getFieldNameByTagSuffix(reflect.TypeOf(tpl), "db", props.ForeignColumnSuffix)
if err != nil {
return false
}
f := reflect.ValueOf(tpl).FieldByName(fieldName)
if f.IsZero() {
return false
}
if f.Kind() == reflect.Ptr {
if f.IsNil() {
return false
}
fieldName, err := getFieldNameByTag(reflect.TypeOf(tpl), "db", props.ForeignColumnName)
f = f.Elem()
}
if err != nil {
return false
}
var fVal objectID
switch f.Kind() {
case reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64:
fVal = intObjectID(f.Int())
case reflect.String:
fVal = strObjectID(f.String())
}
f := reflect.ValueOf(tpl).FieldByName(fieldName)
if fVal == nil {
return false
}
if f.IsZero() {
return false
}
return bytes.Equal(fVal.ToBytes(), objID.ToBytes())
}
if f.Kind() == reflect.Ptr {
if f.IsNil() {
return false
}
// isObjectInUse checks if objID associated with any object in foreignTableProps.
func (d *BoltDb) isObjectInUse(bucketID int, objProps db.ObjectProperties, objID objectID, foreignTableProps db.ObjectProperties) (inUse bool, err error) {
templates := reflect.New(reflect.SliceOf(foreignTableProps.Type))
f = f.Elem()
}
var fVal objectID
switch f.Kind() {
case reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64:
fVal = intObjectID(f.Int())
case reflect.String:
fVal = strObjectID(f.String())
}
if fVal == nil {
return false
}
return bytes.Equal(fVal.ToBytes(), objID.ToBytes())
}, &templates)
err = d.getObjects(bucketID, foreignTableProps, db.RetrieveQueryParams{}, func (foreignObj interface{}) bool {
return isObjectBelongTo(objProps, objID, foreignObj)
}, templates.Interface())
if err != nil {
return
}
inUse = len(templates) > 0
inUse = templates.Elem().Len() > 0
return
}
@ -451,7 +461,7 @@ func (d *BoltDb) updateObject(bucketID int, props db.ObjectProperties, object in
return db.ErrNotFound
}
idFieldName, err := getFieldNameByTag(reflect.TypeOf(object), "db", props.PrimaryColumnName)
idFieldName, err := getFieldNameByTagSuffix(reflect.TypeOf(object), "db", props.PrimaryColumnName)
if err != nil {
return err
@ -510,7 +520,7 @@ func (d *BoltDb) createObject(bucketID int, props db.ObjectProperties, object in
var objectID objectID
if props.PrimaryColumnName != "" {
idFieldName, err := getFieldNameByTag(reflect.TypeOf(object), "db", props.PrimaryColumnName)
idFieldName, err := getFieldNameByTagSuffix(reflect.TypeOf(object), "db", props.PrimaryColumnName)
if err != nil {
return err

View File

@ -190,7 +190,7 @@ func TestSortObjects(t *testing.T) {
}
func TestGetFieldNameByTag(t *testing.T) {
f, err := getFieldNameByTag(reflect.TypeOf(test1{}), "db", "first_name")
f, err := getFieldNameByTagSuffix(reflect.TypeOf(test1{}), "db", "first_name")
if err != nil {
t.Fatal(err.Error())
}
@ -201,7 +201,7 @@ func TestGetFieldNameByTag(t *testing.T) {
}
func TestGetFieldNameByTag2(t *testing.T) {
f, err := getFieldNameByTag(reflect.TypeOf(db.UserWithPwd{}), "db", "id")
f, err := getFieldNameByTagSuffix(reflect.TypeOf(db.UserWithPwd{}), "db", "id")
if err != nil {
t.Fatal(err.Error())
}

View File

@ -9,7 +9,7 @@ func (d *BoltDb) GetAccessKey(projectID int, accessKeyID int) (key db.AccessKey,
if err != nil {
return
}
err = key.DeserializeSecret()
return
}
@ -20,10 +20,26 @@ func (d *BoltDb) GetAccessKeys(projectID int, params db.RetrieveQueryParams) ([]
}
func (d *BoltDb) UpdateAccessKey(key db.AccessKey) error {
err := key.SerializeSecret()
err := key.Validate(key.OverrideSecret)
if err != nil {
return err
}
if key.OverrideSecret {
err = key.SerializeSecret()
if err != nil {
return err
}
} else { // accept only new name, ignore other changes
oldKey, err2 := d.GetAccessKey(*key.ProjectID, key.ID)
if err2 != nil {
return err2
}
oldKey.Name = key.Name
key = oldKey
}
return d.updateObject(*key.ProjectID, db.AccessKeyProps, key)
}
@ -42,43 +58,4 @@ func (d *BoltDb) DeleteAccessKey(projectID int, accessKeyID int) error {
func (d *BoltDb) DeleteAccessKeySoft(projectID int, accessKeyID int) error {
return d.deleteObjectSoft(projectID, db.AccessKeyProps, intObjectID(accessKeyID))
}
func (d *BoltDb) GetGlobalAccessKey(accessKeyID int) (key db.AccessKey, err error) {
err = d.getObject(0, db.GlobalAccessKeyProps, intObjectID(accessKeyID), &key)
if err != nil {
return
}
err = key.DeserializeSecret()
return
}
func (d *BoltDb) GetGlobalAccessKeys(params db.RetrieveQueryParams) (keys []db.AccessKey, err error) {
err = d.getObjects(0, db.GlobalAccessKeyProps, params, nil, &keys)
return
}
func (d *BoltDb) UpdateGlobalAccessKey(key db.AccessKey) error {
err := key.SerializeSecret()
if err != nil {
return err
}
return d.updateObject(0, db.GlobalAccessKeyProps, key)
}
func (d *BoltDb) CreateGlobalAccessKey(key db.AccessKey) (db.AccessKey, error) {
err := key.SerializeSecret()
if err != nil {
return db.AccessKey{}, err
}
newKey, err := d.createObject(0, db.GlobalAccessKeyProps, key)
return newKey.(db.AccessKey), err
}
func (d *BoltDb) DeleteGlobalAccessKey(accessKeyID int) error {
return d.deleteObject(0, db.GlobalAccessKeyProps, intObjectID(accessKeyID))
}
func (d *BoltDb) DeleteGlobalAccessKeySoft(accessKeyID int) error {
return d.deleteObjectSoft(0, db.GlobalAccessKeyProps, intObjectID(accessKeyID))
}
}

View File

@ -22,7 +22,6 @@ func (d *BoltDb) GetInventories(projectID int, params db.RetrieveQueryParams) (i
}
func (d *BoltDb) DeleteInventory(projectID int, inventoryID int) error {
return d.deleteObject(projectID, db.InventoryProps, intObjectID(inventoryID))
}

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

@ -0,0 +1,69 @@
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) (schedules []db.Schedule, err error) {
schedules = make([]db.Schedule, 0)
projSchedules, err := d.GetProjectSchedules(projectID)
if err != nil {
return
}
for _, s := range projSchedules {
if s.TemplateID == templateID {
schedules = append(schedules, s)
}
}
return
}
func (d *BoltDb) CreateSchedule(schedule db.Schedule) (newSchedule db.Schedule, err error) {
newTpl, err := d.createObject(schedule.ProjectID, db.ScheduleProps, schedule)
if err != nil {
return
}
newSchedule = newTpl.(db.Schedule)
return
}
func (d *BoltDb) UpdateSchedule(schedule db.Schedule) error {
return d.updateObject(schedule.ProjectID, db.ScheduleProps, schedule)
}
func (d *BoltDb) GetSchedule(projectID int, scheduleID int) (schedule db.Schedule, err error) {
err = d.getObject(projectID, db.ScheduleProps, intObjectID(scheduleID), &schedule)
return
}
func (d *BoltDb) DeleteSchedule(projectID int, scheduleID int) error {
return d.deleteObject(projectID, db.ScheduleProps, intObjectID(scheduleID))
}

View File

@ -28,7 +28,7 @@ func (d *BoltDb) CreateTaskOutput(output db.TaskOutput) (db.TaskOutput, error) {
return newOutput.(db.TaskOutput), nil
}
func (d *BoltDb) getTasks(projectID int, templateID *int, params db.RetrieveQueryParams) (tasksWithTpl []db.TaskWithTpl, err error) {
func (d *BoltDb) getTasks(projectID int, template *db.Template, params db.RetrieveQueryParams) (tasksWithTpl []db.TaskWithTpl, err error) {
var tasks []db.Task
err = d.getObjects(0, db.TaskProps, params, func(tsk interface{}) bool {
@ -38,13 +38,17 @@ func (d *BoltDb) getTasks(projectID int, templateID *int, params db.RetrieveQuer
return false
}
if templateID != nil && task.TemplateID != *templateID {
if template != nil && task.TemplateID != template.ID {
return false
}
return true
}, &tasks)
if err != nil {
return
}
var templates = make(map[int]db.Template)
var users = make(map[int]db.User)
@ -52,15 +56,17 @@ func (d *BoltDb) getTasks(projectID int, templateID *int, params db.RetrieveQuer
for i, task := range tasks {
tpl, ok := templates[task.TemplateID]
if !ok {
tpl, err = d.GetTemplate(task.ProjectID, task.TemplateID)
if err != nil {
return
if template == nil {
tpl, _ = d.GetTemplate(task.ProjectID, task.TemplateID)
} else {
tpl = *template
}
templates[task.TemplateID] = tpl
}
tasksWithTpl[i] = db.TaskWithTpl{Task: task}
tasksWithTpl[i].TemplatePlaybook = tpl.Playbook
tasksWithTpl[i].TemplateAlias = tpl.Alias
tasksWithTpl[i].TemplateType = tpl.Type
if task.UserID != nil {
usr, ok := users[*task.UserID]
if !ok {
@ -72,6 +78,11 @@ func (d *BoltDb) getTasks(projectID int, templateID *int, params db.RetrieveQuer
}
tasksWithTpl[i].UserName = &usr.Name
}
err = tasksWithTpl[i].Fill(d)
if err != nil {
return
}
}
return
@ -82,15 +93,18 @@ func (d *BoltDb) GetTask(projectID int, taskID int) (task db.Task, err error) {
if err != nil {
return
}
if task.ProjectID != projectID {
task = db.Task{}
err = db.ErrNotFound
return
}
return
}
func (d *BoltDb) GetTemplateTasks(projectID int, templateID int, params db.RetrieveQueryParams) ([]db.TaskWithTpl, error) {
return d.getTasks(projectID, &templateID, params)
func (d *BoltDb) GetTemplateTasks(template db.Template, params db.RetrieveQueryParams) ([]db.TaskWithTpl, error) {
return d.getTasks(template.ProjectID, &template, params)
}
func (d *BoltDb) GetProjectTasks(projectID int, params db.RetrieveQueryParams) ([]db.TaskWithTpl, error) {

View File

@ -18,11 +18,30 @@ func (d *BoltDb) UpdateTemplate(template db.Template) error {
return d.updateObject(template.ProjectID, db.TemplateProps, template)
}
func (d *BoltDb) GetTemplates(projectID int, params db.RetrieveQueryParams) (templates []db.Template, err error) {
err = d.getObjects(projectID, db.TemplateProps, params, nil, &templates)
func (d *BoltDb) getTemplates(projectID int, viewID *int, params db.RetrieveQueryParams) (templates []db.Template, err error) {
var filter func(interface{}) bool
if viewID != nil {
filter = func (tpl interface{}) bool {
template := tpl.(db.Template)
return template.ViewID != nil && *template.ViewID == *viewID
}
}
err = d.getObjects(projectID, db.TemplateProps, params, filter, &templates)
if err != nil {
return
}
err = db.FillTemplates(d, templates)
return
}
func (d *BoltDb) GetTemplates(projectID int, params db.RetrieveQueryParams) ( []db.Template, error) {
return d.getTemplates(projectID, nil, params)
}
func (d *BoltDb) GetTemplate(projectID int, templateID int) (template db.Template, err error) {
err = d.getObject(projectID, db.TemplateProps, intObjectID(templateID), &template)
if err != nil {

46
db/bolt/view.go Normal file
View File

@ -0,0 +1,46 @@
package bolt
import "github.com/ansible-semaphore/semaphore/db"
func (d *BoltDb) GetView(projectID int, viewID int) (view db.View, err error) {
err = d.getObject(projectID, db.ViewProps, intObjectID(viewID), &view)
return
}
func (d *BoltDb) GetViews(projectID int) (views []db.View, err error) {
err = d.getObjects(projectID, db.ViewProps, db.RetrieveQueryParams{}, nil, &views)
return
}
func (d *BoltDb) UpdateView(view db.View) error {
return d.updateObject(view.ProjectID, db.ViewProps, view)
}
func (d *BoltDb) CreateView(view db.View) (db.View, error) {
newView, err := d.createObject(view.ProjectID, db.ViewProps, view)
return newView.(db.View), err
}
func (d *BoltDb) DeleteView(projectID int, viewID int) error {
return d.deleteObject(projectID, db.ViewProps, intObjectID(viewID))
}
func (d *BoltDb) SetViewPositions(projectID int, positions map[int]int) error {
for id, position := range positions {
view, err := d.GetView(projectID, id)
if err != nil {
return err
}
view.Position = position
err = d.UpdateView(view)
if err != nil {
return err
}
}
return nil
}
func (d *BoltDb) GetViewTemplates(projectID int, viewID int, params db.RetrieveQueryParams) ( []db.Template, error) {
return d.getTemplates(projectID, &viewID, params)
}

139
db/bolt/view_test.go Normal file
View File

@ -0,0 +1,139 @@
package bolt
import (
"github.com/ansible-semaphore/semaphore/db"
"sort"
"testing"
"time"
)
func TestGetViews(t *testing.T) {
store := createStore()
err := store.Connect()
if err != nil {
t.Fatal(err.Error())
}
proj1, err := store.CreateProject(db.Project{
Created: time.Now(),
Name: "Test1",
})
if err != nil {
t.Fatal(err.Error())
}
_, err = store.CreateView(db.View{
ProjectID: proj1.ID,
Title: "Test",
Position: 1,
})
if err != nil {
t.Fatal(err.Error())
}
found, err := store.GetViews(proj1.ID)
if err != nil {
t.Fatal(err.Error())
}
if len(found) != 1 {
t.Fatal()
}
view, err := store.GetView(proj1.ID, found[0].ID)
if err != nil {
t.Fatal(err.Error())
}
if view.ID != found[0].ID || view.Title != found[0].Title || view.Position != found[0].Position {
t.Fatal()
}
}
func TestSetViewPositions(t *testing.T) {
store := createStore()
err := store.Connect()
if err != nil {
t.Fatal(err.Error())
}
proj1, err := store.CreateProject(db.Project{
Created: time.Now(),
Name: "Test1",
})
if err != nil {
t.Fatal(err.Error())
}
v1, err := store.CreateView(db.View{
ProjectID: proj1.ID,
Title: "Test",
Position: 4,
})
if err != nil {
t.Fatal(err.Error())
}
v2, err := store.CreateView(db.View{
ProjectID: proj1.ID,
Title: "Test",
Position: 2,
})
if err != nil {
t.Fatal(err.Error())
}
found, err := store.GetViews(proj1.ID)
if err != nil {
t.Fatal(err.Error())
}
if len(found) != 2 {
t.Fatal()
}
sort.Slice(found, func(i, j int) bool {
return found[i].Position < found[j].Position
})
if found[0].Position != v2.Position || found[1].Position != v1.Position {
t.Fatal()
}
err = store.SetViewPositions(proj1.ID, map[int]int{
v1.ID: 3,
v2.ID: 6,
})
if err != nil {
t.Fatal(err.Error())
}
found, err = store.GetViews(proj1.ID)
if err != nil {
t.Fatal(err.Error())
}
if len(found) != 2 {
t.Fatal()
}
sort.Slice(found, func(i, j int) bool {
return found[i].Position < found[j].Position
})
if found[0].Position != 3 || found[1].Position != 6 {
t.Fatal()
}
}

View File

@ -51,6 +51,9 @@ var (
// validateMutationResult checks the success of the update query
func validateMutationResult(res sql.Result, err error) error {
if err != nil {
if strings.Contains(err.Error(), "foreign key") {
err = db.ErrInvalidOperation
}
return err
}
@ -166,12 +169,12 @@ func createDb() error {
return err
}
db, err := sql.Open(cfg.Dialect.String(), connectionString)
conn, err := sql.Open(cfg.Dialect.String(), connectionString)
if err != nil {
return err
}
_, err = db.Exec("create database " + cfg.DbName)
_, err = conn.Exec("create database " + cfg.DbName)
if err != nil {
log.Warn(err.Error())
@ -216,12 +219,14 @@ func (d *SqlDb) getObjects(projectID int, props db.ObjectProperties, params db.R
orderDirection = "DESC"
}
orderColumn := "name"
orderColumn := props.DefaultSortingColumn
if containsStr(props.SortableColumns, params.SortBy) {
orderColumn = params.SortBy
}
q = q.OrderBy("pe." + orderColumn + " " + orderDirection)
if orderColumn != "" {
q = q.OrderBy("pe." + orderColumn + " " + orderDirection)
}
query, args, err := q.ToSql()
@ -234,34 +239,7 @@ func (d *SqlDb) getObjects(projectID int, props db.ObjectProperties, params db.R
return
}
func (d *SqlDb) isObjectInUse(projectID int, props db.ObjectProperties, objectID int) (bool, error) {
if props.ForeignColumnName == "" {
return false, nil
}
templatesC, err := d.sql.SelectInt(
"select count(1) from project__template where project_id=? and " + props.ForeignColumnName+ "=?",
projectID,
objectID)
if err != nil {
return false, err
}
return templatesC > 0, nil
}
func (d *SqlDb) deleteObject(projectID int, props db.ObjectProperties, objectID int) error {
inUse, err := d.isObjectInUse(projectID, props, objectID)
if err != nil {
return err
}
if inUse {
return db.ErrInvalidOperation
}
return validateMutationResult(
d.exec(
"delete from " + props.TableName + " where project_id=? and id=?",

View File

@ -47,12 +47,13 @@ func (version *Version) GetErrPath() string {
}
// GetSQL takes a path to an SQL file and returns it from packr as a slice of strings separated by newlines
func (version *Version) GetSQL(path string) []string {
func (version *Version) GetSQL(path string) (queries []string) {
sql, err := dbAssets.MustString(path)
if err != nil {
panic(err)
}
return strings.Split(sql, ";\n")
queries = strings.Split(strings.ReplaceAll(sql, ";\r\n", ";\n"), ";\n")
return
}
func init() {
@ -81,5 +82,10 @@ 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},
{Major: 2, Minor: 8, Patch: 0},
{Major: 2, Minor: 8, Patch: 1},
{Major: 2, Minor: 8, Patch: 7},
{Major: 2, Minor: 8, Patch: 8},
}
}

View File

@ -1,6 +1,9 @@
package sql
import "github.com/ansible-semaphore/semaphore/db"
import (
"database/sql"
"github.com/ansible-semaphore/semaphore/db"
)
func (d *SqlDb) GetAccessKey(projectID int, accessKeyID int) (key db.AccessKey, err error) {
err = d.getObject(projectID, db.AccessKeyProps, accessKeyID, &key)
@ -9,8 +12,6 @@ func (d *SqlDb) GetAccessKey(projectID int, accessKeyID int) (key db.AccessKey,
return
}
err = key.DeserializeSecret()
return
}
@ -21,18 +22,37 @@ func (d *SqlDb) GetAccessKeys(projectID int, params db.RetrieveQueryParams) ([]d
}
func (d *SqlDb) UpdateAccessKey(key db.AccessKey) error {
err := key.SerializeSecret()
err := key.Validate(key.OverrideSecret)
if err != nil {
return err
}
res, err := d.exec(
"update access_key set name=?, type=?, secret=? where project_id=? and id=?",
key.Name,
key.Type,
key.Secret,
key.ProjectID,
key.ID)
err = key.SerializeSecret()
if err != nil {
return err
}
var res sql.Result
var args []interface{}
query := "update access_key set name=?"
args = append(args, key.Name)
if key.OverrideSecret {
query += ", type=?, secret=?"
args = append(args, key.Type)
args = append(args, key.Secret)
}
query += " where id=?"
args = append(args, key.ID)
query += " and project_id=?"
args = append(args, key.ProjectID)
res, err = d.exec(query, args...)
return validateMutationResult(res, err)
}
@ -66,62 +86,4 @@ func (d *SqlDb) DeleteAccessKey(projectID int, accessKeyID int) error {
func (d *SqlDb) DeleteAccessKeySoft(projectID int, accessKeyID int) error {
return d.deleteObjectSoft(projectID, db.AccessKeyProps, accessKeyID)
}
func (d *SqlDb) GetGlobalAccessKey(accessKeyID int) (db.AccessKey, error) {
var key db.AccessKey
err := d.getObject(0, db.GlobalAccessKeyProps, accessKeyID, &key)
return key, err
}
func (d *SqlDb) GetGlobalAccessKeys(params db.RetrieveQueryParams) ([]db.AccessKey, error) {
var keys []db.AccessKey
err := d.getObjects(0, db.GlobalAccessKeyProps, params, &keys)
return keys, err
}
func (d *SqlDb) UpdateGlobalAccessKey(key db.AccessKey) error {
err := key.SerializeSecret()
if err != nil {
return err
}
res, err := d.exec(
"update access_key set name=?, type=?, secret=? where id=?",
key.Name,
key.Type,
key.Secret,
key.ID)
return validateMutationResult(res, err)
}
func (d *SqlDb) CreateGlobalAccessKey(key db.AccessKey) (newKey db.AccessKey, err error) {
err = key.SerializeSecret()
if err != nil {
return
}
insertID, err := d.insert(
"id",
"insert into access_key (name, type, secret) values (?, ?, ?)",
key.Name,
key.Type,
key.Secret)
if err != nil {
return
}
newKey = key
newKey.ID = insertID
return
}
func (d *SqlDb) DeleteGlobalAccessKey(accessKeyID int) error {
return d.deleteObject(0, db.GlobalAccessKeyProps, accessKeyID)
}
func (d *SqlDb) DeleteGlobalAccessKeySoft(accessKeyID int) error {
return d.deleteObjectSoft(0, db.GlobalAccessKeyProps, accessKeyID)
}
}

View File

@ -28,11 +28,12 @@ func (d *SqlDb) DeleteInventorySoft(projectID int, inventoryID int) error {
func (d *SqlDb) UpdateInventory(inventory db.Inventory) error {
_, err := d.exec(
"update project__inventory set name=?, type=?, ssh_key_id=?, inventory=? where id=?",
"update project__inventory set name=?, type=?, ssh_key_id=?, inventory=?, become_key_id=? where id=?",
inventory.Name,
inventory.Type,
inventory.SSHKeyID,
inventory.Inventory,
inventory.BecomeKeyID,
inventory.ID)
return err
@ -41,12 +42,13 @@ func (d *SqlDb) UpdateInventory(inventory db.Inventory) error {
func (d *SqlDb) CreateInventory(inventory db.Inventory) (newInventory db.Inventory, err error) {
insertID, err := d.insert(
"id",
"insert into project__inventory (project_id, name, type, ssh_key_id, inventory) values (?, ?, ?, ?, ?)",
"insert into project__inventory (project_id, name, type, ssh_key_id, inventory, become_key_id) values (?, ?, ?, ?, ?, ?)",
inventory.ProjectID,
inventory.Name,
inventory.Type,
inventory.SSHKeyID,
inventory.Inventory)
inventory.Inventory,
inventory.BecomeKeyID)
if err != nil {
return

View File

@ -71,9 +71,11 @@ 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)
log.Warnf("\n ERR! Query: %s\n\n", q)
log.Fatalf(err.Error())
return err
}
}

View File

@ -7,12 +7,12 @@ alter table `project__inventory` add `ssh_key_id` int null references access_key
alter table `task__output` rename to `task__output_backup`;
create table `task__output`
(
task_id int not null
references task
on delete cascade,
task_id int not null,
task varchar(255) not null,
time datetime not null,
output longtext not null
output longtext not null,
foreign key (`task_id`) references task(`id`) on delete cascade
);
insert into `task__output` select * from `task__output_backup`;
drop table `task__output_backup`;

View File

@ -3,12 +3,12 @@ alter table task__output rename to task__output_backup;
create table task__output
(
id integer primary key autoincrement,
task_id int not null
references task
on delete cascade,
task_id int not null,
task varchar(255) not null,
time datetime not null,
output longtext not null
output longtext not null,
foreign key (`task_id`) references task(`id`) on delete cascade
);
insert into task__output(task_id, task, time, output) select * from task__output_backup;

View File

@ -7,9 +7,9 @@ create table user__token
id varchar(44) not null primary key,
created datetime not null,
expired boolean default false not null,
user_id int not null
references `user`
on delete cascade
user_id int not null,
foreign key (`user_id`) references `user`(`id`) on delete cascade
);
insert into user__token select * from user__token_backup;

View File

@ -1,2 +1,2 @@
alter table `project__inventory` add `become_key_id` int references access_key(`id`);
alter table `project__template` add `vault_pass_id` int references access_key(`id`);
alter table `project__template` add `vault_key_id` int references access_key(`id`);

View File

@ -0,0 +1,9 @@
drop table project__template_schedule;
create table `project__schedule`
(
`id` integer primary key autoincrement,
`template_id` int references project__template (`id`) on delete cascade,
`project_id` int not null references project (`id`) on delete cascade,
`cron_format` varchar(255) not null
);

View File

@ -0,0 +1,7 @@
alter table project__template add `type` varchar(10) not null default '';
alter table `task` add `message` varchar(250) not null default '';
alter table project__template add start_version varchar(20);
alter table project__template add build_template_id int references project__template(id);
alter table `task` add `version` varchar(20);
alter table `task` add commit_hash varchar(40);
alter table `task` add commit_message varchar(100) not null default '';

View File

@ -0,0 +1 @@
alter table `task` add build_task_id int references `task`(id);

View File

@ -0,0 +1 @@
alter table `task` drop column `arguments`;

View File

@ -0,0 +1,9 @@
create table `project__view` (
`id` integer primary key autoincrement,
`title` varchar(100) not null,
`project_id` int not null,
`position` int not null,
foreign key (`project_id`) references project(`id`) on delete cascade
);
alter table `project__template` add view_id int references `project__view`(id) on delete set null;

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

@ -0,0 +1,66 @@
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 from 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__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__schedule where project_id=? and template_id=?",
projectID,
templateID)
return
}

View File

@ -13,7 +13,7 @@ func (d *SqlDb) CreateTask(task db.Task) (db.Task, error) {
func (d *SqlDb) UpdateTask(task db.Task) error {
_, err := d.exec(
"update task set status=?, start=?, end=? where id=?",
"update task set status=?, start=?, `end`=? where id=?",
task.Status,
task.Start,
task.End,
@ -31,8 +31,15 @@ func (d *SqlDb) CreateTaskOutput(output db.TaskOutput) (db.TaskOutput, error) {
return output, err
}
func (d *SqlDb) getTasks(projectID int, templateID* int, params db.RetrieveQueryParams) (tasks []db.TaskWithTpl, err error) {
q := squirrel.Select("task.*, tpl.playbook as tpl_playbook, `user`.name as user_name, tpl.alias as tpl_alias").
func (d *SqlDb) getTasks(projectID int, templateID* int, params db.RetrieveQueryParams, tasks *[]db.TaskWithTpl) (err error) {
fields := "task.*"
fields += ", tpl.playbook as tpl_playbook" +
", `user`.name as user_name" +
", tpl.alias as tpl_alias" +
", tpl.type as tpl_type"
q := squirrel.Select(fields).
From("task").
Join("project__template as tpl on task.template_id=tpl.id").
LeftJoin("`user` on task.user_id=`user`.id").
@ -50,11 +57,19 @@ func (d *SqlDb) getTasks(projectID int, templateID* int, params db.RetrieveQuery
query, args, _ := q.ToSql()
_, err = d.selectAll(&tasks, query, args...)
_, err = d.selectAll(tasks, query, args...)
for i := range *tasks {
err = (*tasks)[i].Fill(d)
if err != nil {
return
}
}
return
}
func (d *SqlDb) GetTask(projectID int, taskID int) (task db.Task, err error) {
q := squirrel.Select("task.*").
From("task").
@ -71,17 +86,24 @@ func (d *SqlDb) GetTask(projectID int, taskID int) (task db.Task, err error) {
if err == sql.ErrNoRows {
err = db.ErrNotFound
return
}
if err != nil {
return
}
return
}
func (d *SqlDb) GetTemplateTasks(projectID int, templateID int, params db.RetrieveQueryParams) ([]db.TaskWithTpl, error) {
return d.getTasks(projectID, &templateID, params)
func (d *SqlDb) GetTemplateTasks(template db.Template, params db.RetrieveQueryParams) (tasks []db.TaskWithTpl, err error) {
err = d.getTasks(template.ProjectID, &template.ID, params, &tasks)
return
}
func (d *SqlDb) GetProjectTasks(projectID int, params db.RetrieveQueryParams) ([]db.TaskWithTpl, error) {
return d.getTasks(projectID, nil, params)
func (d *SqlDb) GetProjectTasks(projectID int, params db.RetrieveQueryParams) (tasks []db.TaskWithTpl, err error) {
err = d.getTasks(projectID, nil, params, &tasks)
return
}
func (d *SqlDb) DeleteTaskWithOutputs(projectID int, taskID int) (err error) {

View File

@ -9,8 +9,10 @@ import (
func (d *SqlDb) CreateTemplate(template db.Template) (newTemplate db.Template, err error) {
insertID, err := d.insert(
"id",
"insert into project__template (project_id, inventory_id, repository_id, environment_id, alias, playbook, arguments, override_args)" +
"values (?, ?, ?, ?, ?, ?, ?, ?)",
"insert into project__template (project_id, inventory_id, repository_id, environment_id, " +
"alias, playbook, arguments, override_args, description, vault_key_id, `type`, start_version," +
"build_template_id, view_id)" +
"values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
template.ProjectID,
template.InventoryID,
template.RepositoryID,
@ -18,7 +20,19 @@ func (d *SqlDb) CreateTemplate(template db.Template) (newTemplate db.Template, e
template.Alias,
template.Playbook,
template.Arguments,
template.OverrideArguments)
template.OverrideArguments,
template.Description,
template.VaultKeyID,
template.Type,
template.StartVersion,
template.BuildTemplateID,
template.ViewID)
if err != nil {
return
}
err = db.FillTemplate(d, &newTemplate)
if err != nil {
return
@ -26,13 +40,26 @@ func (d *SqlDb) CreateTemplate(template db.Template) (newTemplate db.Template, e
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=?",
_, err := d.exec("update project__template set " +
"inventory_id=?, " +
"repository_id=?, " +
"environment_id=?, " +
"alias=?, " +
"playbook=?, " +
"arguments=?, " +
"override_args=?, " +
"description=?, " +
"vault_key_id=?, " +
"`type`=?, " +
"start_version=?," +
"build_template_id=?, " +
"view_id=? " +
"where removed = false and id=? and project_id=?",
template.InventoryID,
template.RepositoryID,
template.EnvironmentID,
@ -40,12 +67,18 @@ func (d *SqlDb) UpdateTemplate(template db.Template) error {
template.Playbook,
template.Arguments,
template.OverrideArguments,
template.ID)
template.Description,
template.VaultKeyID,
template.Type,
template.StartVersion,
template.BuildTemplateID,
template.ViewID,
template.ID,
template.ProjectID,
)
return err
}
func (d *SqlDb) GetTemplates(projectID int, params db.RetrieveQueryParams) (templates []db.Template, err error) {
func (d *SqlDb) getTemplates(projectID int, viewID *int, params db.RetrieveQueryParams) (templates []db.Template, err error) {
q := squirrel.Select("pt.id",
"pt.project_id",
"pt.inventory_id",
@ -54,10 +87,17 @@ func (d *SqlDb) GetTemplates(projectID int, params db.RetrieveQueryParams) (temp
"pt.alias",
"pt.playbook",
"pt.arguments",
"pt.override_args").
"pt.override_args",
"pt.vault_key_id",
"pt.view_id",
"pt.`type`").
From("project__template pt").
Where("pt.removed = false")
if viewID != nil {
q = q.Where("pt.view_id=?", *viewID)
}
order := "ASC"
if params.SortInverted {
order = "DESC"
@ -91,9 +131,20 @@ func (d *SqlDb) GetTemplates(projectID int, params db.RetrieveQueryParams) (temp
}
_, err = d.selectAll(&templates, query, args...)
if err != nil {
return
}
err = db.FillTemplates(d, templates)
return
}
func (d *SqlDb) GetTemplates(projectID int, params db.RetrieveQueryParams) ( []db.Template, error) {
return d.getTemplates(projectID, nil, params)
}
func (d *SqlDb) GetTemplate(projectID int, templateID int) (template db.Template, err error) {
err = d.selectOne(
&template,
@ -117,11 +168,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)
}

62
db/sql/view.go Normal file
View File

@ -0,0 +1,62 @@
package sql
import "github.com/ansible-semaphore/semaphore/db"
func (d *SqlDb) GetView(projectID int, viewID int) (view db.View, err error) {
err = d.getObject(projectID, db.ViewProps, viewID, &view)
return
}
func (d *SqlDb) GetViews(projectID int) (views []db.View, err error) {
err = d.getObjects(projectID, db.ViewProps, db.RetrieveQueryParams{}, &views)
return
}
func (d *SqlDb) UpdateView(view db.View) error {
_, err := d.exec(
"update project__view set title=?, position=?, project_id=? where id=?",
view.Title,
view.Position,
view.ProjectID,
view.ID)
return err
}
func (d *SqlDb) CreateView(view db.View) (newView db.View, err error) {
insertID, err := d.insert(
"id",
"insert into project__view (project_id, title, position) values (?, ?, ?)",
view.ProjectID,
view.Title,
view.Position)
if err != nil {
return
}
newView = view
newView.ID = insertID
return
}
func (d *SqlDb) DeleteView(projectID int, viewID int) error {
return d.deleteObject(projectID, db.ViewProps, viewID)
}
func (d *SqlDb) SetViewPositions(projectID int, positions map[int]int) error {
for id, position := range positions {
_, err := d.exec("update project__view set position=? where project_id=? and id=?",
position,
projectID,
id)
if err != nil {
return err
}
}
return nil
}
func (d *SqlDb) GetViewTemplates(projectID int, viewID int, params db.RetrieveQueryParams) ( []db.Template, error) {
return d.getTemplates(projectID, &viewID, params)
}

View File

@ -20,6 +20,7 @@ services:
context: ./../../../
dockerfile: ./deployment/docker/ci/Dockerfile
environment:
SEMAPHORE_DB_DIALECT: mysql
SEMAPHORE_DB_USER: semaphore
SEMAPHORE_DB_PASS: semaphore
SEMAPHORE_DB_HOST: mysql

View File

@ -8,6 +8,8 @@ SEMAPHORE_CONFIG_PATH="${SEMAPHORE_CONFIG_PATH:-/etc/semaphore}"
SEMAPHORE_TMP_PATH="${SEMAPHORE_TMP_PATH:-/tmp/semaphore}"
# Semaphore database env config
SEMAPHORE_DB_DIALECT="${SEMAPHORE_DB_DIALECT:-mysql}"
SEMAPHORE_DB_DIALECT_ID=1
SEMAPHORE_DB_HOST="${SEMAPHORE_DB_HOST:-0.0.0.0}"
SEMAPHORE_DB_PORT="${SEMAPHORE_DB_PORT:-3306}"
SEMAPHORE_DB="${SEMAPHORE_DB:-semaphore}"
@ -48,7 +50,7 @@ SEMAPHORE_LDAP_MAPPING_EMAIL="${SEMAPHORE_LDAP_MAPPING_EMAIL:-mail}"
# wait on db to be up
echoerr "Attempting to connect to database ${SEMAPHORE_DB} on ${SEMAPHORE_DB_HOST}:${SEMAPHORE_DB_PORT} with user ${SEMAPHORE_DB_USER} ..."
TIMEOUT=30
while ! mysqladmin ping -h"$SEMAPHORE_DB_HOST" -P "$SEMAPHORE_DB_PORT" -u "$SEMAPHORE_DB_USER" --password="$SEMAPHORE_DB_PASS" --silent >/dev/null 2>&1; do
while ! $(nc -z "$SEMAPHORE_DB_HOST" "$SEMAPHORE_DB_PORT") >/dev/null 2>&1; do
TIMEOUT=$(expr $TIMEOUT - 1)
if [ $TIMEOUT -eq 0 ]; then
echoerr "Could not connect to database server. Exiting."
@ -58,11 +60,17 @@ while ! mysqladmin ping -h"$SEMAPHORE_DB_HOST" -P "$SEMAPHORE_DB_PORT" -u "$SEMA
sleep 1
done
case ${SEMAPHORE_DB_DIALECT} in
"mysql") SEMAPHORE_DB_DIALECT_ID=1;;
"bolt") SEMAPHORE_DB_DIALECT_ID=2;;
"postgres") SEMAPHORE_DB_DIALECT_ID=3;;
esac
# Create a config if it does not exist in the current config path
if [ ! -f "${SEMAPHORE_CONFIG_PATH}/config.json" ]; then
echoerr "Generating ${SEMAPHORE_TMP_PATH}/config.stdin ..."
cat << EOF > "${SEMAPHORE_TMP_PATH}/config.stdin"
1
${SEMAPHORE_DB_DIALECT_ID}
${SEMAPHORE_DB_HOST}:${SEMAPHORE_DB_PORT}
${SEMAPHORE_DB_USER}
${SEMAPHORE_DB_PASS}

View File

@ -22,6 +22,7 @@ services:
volumes:
- "./../../../:/go/src/github.com/ansible-semaphore/semaphore:rw"
environment:
SEMAPHORE_DB_DIALECT: mysql
SEMAPHORE_DB_USER: semaphore
SEMAPHORE_DB_PASS: semaphore
SEMAPHORE_DB_HOST: mysql

View File

@ -18,6 +18,7 @@ services:
context: ./../../../
dockerfile: ./deployment/docker/prod/Dockerfile
environment:
SEMAPHORE_DB_DIALECT: mysql
SEMAPHORE_DB_USER: semaphore
SEMAPHORE_DB_PASS: hx4hjxqthfwbfsy5535u4agfdscm
SEMAPHORE_DB_HOST: mysql

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

@ -23,18 +23,19 @@ var WebHostURL *url.URL
type DbDriver string
const (
DbDriverMySQL DbDriver = "mysql"
DbDriverBolt DbDriver = "bolt"
DbDriverMySQL DbDriver = "mysql"
DbDriverBolt DbDriver = "bolt"
DbDriverPostgres DbDriver = "postgres"
)
type DbConfig struct {
Dialect DbDriver `json:"-"`
Dialect DbDriver `json:"-"`
Hostname string `json:"host"`
Username string `json:"user"`
Password string `json:"pass"`
DbName string `json:"name"`
Hostname string `json:"host"`
Username string `json:"user"`
Password string `json:"pass"`
DbName string `json:"name"`
Options map[string]string `json:"options"`
}
type ldapMappings struct {
@ -44,10 +45,19 @@ type ldapMappings struct {
CN string `json:"cn"`
}
type VariablesPassingMethod string
const (
VariablesPassingNone VariablesPassingMethod = "none"
VariablesPassingEnv VariablesPassingMethod = "env_vars"
VariablesPassingExtra VariablesPassingMethod = "extra_vars"
VariablesPassingBoth VariablesPassingMethod = ""
)
//ConfigType mapping between Config and the json file that sets it
type ConfigType struct {
MySQL DbConfig `json:"mysql"`
BoltDb DbConfig `json:"bolt"`
MySQL DbConfig `json:"mysql"`
BoltDb DbConfig `json:"bolt"`
Postgres DbConfig `json:"postgres"`
Dialect DbDriver `json:"dialect"`
@ -64,14 +74,16 @@ type ConfigType struct {
TmpPath string `json:"tmp_path"`
// cookie hashing & encryption
CookieHash string `json:"cookie_hash"`
CookieEncryption string `json:"cookie_encryption"`
AccessKeyEncryption string `json:"access_key_encryption"`
CookieHash string `json:"cookie_hash"`
CookieEncryption string `json:"cookie_encryption"`
AccessKeyEncryption string `json:"access_key_encryption"`
// email alerting
EmailSender string `json:"email_sender"`
EmailHost string `json:"email_host"`
EmailPort string `json:"email_port"`
EmailSender string `json:"email_sender"`
EmailHost string `json:"email_host"`
EmailPort string `json:"email_port"`
EmailUsername string `json:"email_username"`
EmailPassword string `json:"email_password"`
// web host
WebHost string `json:"web_host"`
@ -97,9 +109,18 @@ type ConfigType struct {
// feature switches
EmailAlert bool `json:"email_alert"`
EmailSecure bool `json:"email_secure"`
TelegramAlert bool `json:"telegram_alert"`
LdapEnable bool `json:"ldap_enable"`
LdapNeedTLS bool `json:"ldap_needtls"`
SshConfigPath string `json:"ssh_config_path"`
// VariablesPassingMethod defines how Semaphore will pass variables to Ansible.
// Default both via environment variables and via extra vars.
VariablesPassingMethod VariablesPassingMethod `json:"variables_passing_method"`
}
//Config exposes the application configuration storage for use in the application
@ -197,6 +218,19 @@ func decodeConfig(file io.Reader) {
}
}
func mapToQueryString(m map[string]string) (str string) {
for option, value := range m {
if str != "" {
str += "&"
}
str += option + "=" + value
}
if str != "" {
str = "?" + str
}
return
}
// String returns dialect name for GORP.
func (d DbDriver) String() string {
return string(d)
@ -217,18 +251,26 @@ func (d *DbConfig) GetConnectionString(includeDbName bool) (connectionString str
case DbDriverMySQL:
if includeDbName {
connectionString = fmt.Sprintf(
"%s:%s@tcp(%s)/%s?parseTime=true&interpolateParams=true",
"%s:%s@tcp(%s)/%s",
d.Username,
d.Password,
d.Hostname,
d.DbName)
} else {
connectionString = fmt.Sprintf(
"%s:%s@tcp(%s)/?parseTime=true&interpolateParams=true",
"%s:%s@tcp(%s)/",
d.Username,
d.Password,
d.Hostname)
}
options := map[string]string{
"parseTime": "true",
"interpolateParams": "true",
}
for v, k := range d.Options {
options[v] = k
}
connectionString += mapToQueryString(options)
case DbDriverPostgres:
if includeDbName {
connectionString = fmt.Sprintf(
@ -244,6 +286,7 @@ func (d *DbConfig) GetConnectionString(includeDbName bool) (connectionString str
d.Password,
d.Hostname)
}
connectionString += mapToQueryString(d.Options)
default:
err = fmt.Errorf("unsupported database driver: %s", d.Dialect)
}

View File

@ -2,9 +2,9 @@ package util
import (
"bytes"
"net/smtp"
log "github.com/Sirupsen/logrus"
"io"
"net/smtp"
)
// SendMail dispatches a mail using smtp
@ -14,7 +14,7 @@ func SendMail(emailHost, mailSender, mailRecipient string, mail bytes.Buffer) er
return err
}
defer func (c *smtp.Client) {
defer func(c *smtp.Client) {
err = c.Close()
if err != nil {
log.Error(err)
@ -37,7 +37,7 @@ func SendMail(emailHost, mailSender, mailRecipient string, mail bytes.Buffer) er
return err
}
defer func (wc io.WriteCloser) {
defer func(wc io.WriteCloser) {
err = wc.Close()
if err != nil {
log.Error(err)
@ -46,3 +46,22 @@ func SendMail(emailHost, mailSender, mailRecipient string, mail bytes.Buffer) er
_, err = mail.WriteTo(wc)
return err
}
// SendSecureMail dispatches a mail using smtp with authentication and StartTLS
func SendSecureMail(emailHost, emailPort, mailSender, mailUsername, mailPassword, mailRecipient string, mail bytes.Buffer) error {
// Receiver email address.
to := []string{
mailRecipient,
}
// Authentication.
auth := smtp.PlainAuth("", mailUsername, mailPassword, emailHost)
// Sending email.
err := smtp.SendMail(emailHost+":"+emailPort, auth, mailSender, to, mail.Bytes())
if err != nil {
log.Error(err)
}
return err
}

View File

@ -13,6 +13,7 @@ module.exports = {
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'linebreak-style': 'off',
},
overrides: [
{

1651
web2/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,12 +10,14 @@
},
"dependencies": {
"@mdi/font": "^5.8.55",
"axios": "^0.21.1",
"axios": "^0.21.4",
"core-js": "^3.6.5",
"dredd": "^13.1.2",
"moment": "^2.29.1",
"vue": "^2.6.11",
"vue-codemirror": "^4.0.6",
"vue-router": "^3.2.0",
"vuedraggable": "^2.24.3",
"vuetify": "^2.2.11"
},
"devDependencies": {

View File

@ -1,126 +1,131 @@
<template>
<v-app v-if="state === 'success'" class="app">
<EditDialog
v-model="passwordDialog"
save-button-text="Save"
title="Change password"
v-if="user"
event-name="i-user"
v-model="passwordDialog"
save-button-text="Save"
title="Change password"
v-if="user"
event-name="i-user"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<ChangePasswordForm
:project-id="projectId"
:item-id="user.id"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
:project-id="projectId"
:item-id="user.id"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</EditDialog>
<EditDialog
v-model="userDialog"
save-button-text="Save"
title="Edit User"
v-if="user"
event-name="i-user"
v-model="userDialog"
save-button-text="Save"
title="Edit User"
v-if="user"
event-name="i-user"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<UserForm
:project-id="projectId"
:item-id="user.id"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
:project-id="projectId"
:item-id="user.id"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</EditDialog>
<EditDialog
v-model="taskLogDialog"
save-button-text="Delete"
:max-width="1000"
:hide-buttons="true"
@close="onTaskLogDialogClosed()"
v-model="taskLogDialog"
save-button-text="Delete"
:max-width="1000"
:hide-buttons="true"
@close="onTaskLogDialogClosed()"
>
<template v-slot:title={}>
<router-link
class="breadcrumbs__item breadcrumbs__item--link"
:to="`/project/${projectId}/templates/${template ? template.id : null}`"
@click="taskLogDialog = false"
>{{ template ? template.alias : null }}</router-link>
<v-icon>mdi-chevron-right</v-icon>
<span class="breadcrumbs__item">Task #{{ task ? task.id : null }}</span>
<div class="text-truncate" style="max-width: calc(100% - 36px);">
<router-link
class="breadcrumbs__item breadcrumbs__item--link"
:to="`/project/${projectId}/templates/${template ? template.id : null}`"
@click="taskLogDialog = false"
>{{ template ? template.alias : null }}
</router-link>
<v-icon>mdi-chevron-right</v-icon>
<span class="breadcrumbs__item">Task #{{ task ? task.id : null }}</span>
</div>
<v-spacer></v-spacer>
<v-btn
icon
icon
@click="taskLogDialog = false; onTaskLogDialogClosed()"
>
<v-icon @click="taskLogDialog = false; onTaskLogDialogClosed()">mdi-close</v-icon>
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
<template v-slot:form="{}">
<TaskLogView :project-id="projectId" :item-id="task ? task.id : null" />
<TaskLogView :project-id="projectId" :item-id="task ? task.id : null"/>
</template>
</EditDialog>
<EditDialog
v-model="newProjectDialog"
save-button-text="Create"
title="New Project"
event-name="i-project"
v-model="newProjectDialog"
save-button-text="Create"
title="New Project"
event-name="i-project"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<ProjectForm
item-id="new"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
item-id="new"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</EditDialog>
<v-snackbar
v-model="snackbar"
:color="snackbarColor"
:timeout="3000"
top
v-model="snackbar"
:color="snackbarColor"
:timeout="3000"
top
>
{{ snackbarText }}
<v-btn
text
@click="snackbar = false"
text
@click="snackbar = false"
>
Close
</v-btn>
</v-snackbar>
<v-navigation-drawer
app
dark
color="#005057"
fixed
width="260"
v-model="drawer"
mobile-breakpoint="960"
v-if="$route.path.startsWith('/project/')"
app
dark
color="#005057"
fixed
width="260"
v-model="drawer"
mobile-breakpoint="960"
v-if="$route.path.startsWith('/project/')"
>
<v-menu bottom max-width="235" max-height="100%" v-if="project">
<template v-slot:activator="{ on, attrs }">
<v-list class="pa-0 overflow-y-auto">
<v-list-item
key="project"
class="app__project-selector"
v-bind="attrs"
v-on="on"
key="project"
class="app__project-selector"
v-bind="attrs"
v-on="on"
>
<v-list-item-icon>
<v-avatar
:color="getProjectColor(project)"
size="24"
style="font-size: 13px; font-weight: bold;"
:color="getProjectColor(project)"
size="24"
style="font-size: 13px; font-weight: bold;"
>
<span class="white--text">{{ getProjectInitials(project) }}</span>
</v-avatar>
@ -140,16 +145,16 @@
</template>
<v-list>
<v-list-item
v-for="(item, i) in projects"
:key="i"
:to="`/project/${item.id}`"
@click="selectProject(item.id)"
v-for="(item, i) in projects"
:key="i"
:to="`/project/${item.id}`"
@click="selectProject(item.id)"
>
<v-list-item-icon>
<v-avatar
:color="getProjectColor(item)"
size="24"
style="font-size: 13px; font-weight: bold;"
:color="getProjectColor(item)"
size="24"
style="font-size: 13px; font-weight: bold;"
>
<span class="white--text">{{ getProjectInitials(item) }}</span>
</v-avatar>
@ -192,7 +197,7 @@
</v-list-item-content>
</v-list-item>
<v-list-item key="templates" :to="`/project/${projectId}/templates`">
<v-list-item key="templates" :to="templatesUrl">
<v-list-item-icon>
<v-icon>mdi-check-all</v-icon>
</v-list-item-icon>
@ -258,9 +263,9 @@
<template v-slot:activator="{ on, attrs }">
<v-list class="pa-0">
<v-list-item
key="project"
v-bind="attrs"
v-on="on"
key="project"
v-bind="attrs"
v-on="on"
>
<v-list-item-icon>
<v-icon>mdi-account</v-icon>
@ -326,16 +331,16 @@
<v-app v-else-if="state === 'loading'">
<v-main>
<v-container
fluid
fill-height
align-center
justify-center
class="pa-0"
fluid
fill-height
align-center
justify-center
class="pa-0"
>
<v-progress-circular
:size="70"
color="primary"
indeterminate
:size="70"
color="primary"
indeterminate
></v-progress-circular>
</v-container>
</v-main>
@ -343,12 +348,12 @@
<v-app v-else-if="state === 'error'">
<v-main>
<v-container
fluid
flex-column
fill-height
align-center
justify-center
class="pa-0 text-center"
fluid
flex-column
fill-height
align-center
justify-center
class="pa-0 text-center"
>
<v-alert text color="error" class="d-inline-block">
<h3 class="headline">
@ -372,16 +377,30 @@
<v-app v-else></v-app>
</template>
<style lang="scss">
.v-dialog > .v-card > .v-card__title {
flex-wrap: nowrap;
overflow: hidden;
& * {
white-space: nowrap;
}
}
.breadcrumbs {
.v-data-table tbody tr.v-data-table__expanded__content {
box-shadow: none !important;
}
.breadcrumbs__item {
.v-data-table a {
text-decoration-line: none;
&:hover {
text-decoration-line: underline;
}
}
.breadcrumbs__item--link {
text-decoration-line: none;
&:hover {
text-decoration-line: underline;
}
@ -393,6 +412,7 @@
.app__project-selector {
height: 64px;
.v-list-item__icon {
margin-top: 20px !important;
}
@ -422,17 +442,13 @@
.v-data-table > .v-data-table__wrapper > table > tbody > tr {
background: transparent !important;
& > td {
white-space: nowrap;
}
& > td:first-child {
//font-weight: bold !important;
a {
text-decoration-line: none;
&:hover {
text-decoration-line: underline;
}
}
}
}
@ -521,8 +537,8 @@ export default {
watch: {
async projects(val) {
if (val.length === 0
&& this.$route.path.startsWith('/project/')
&& this.$route.path !== '/project/new') {
&& this.$route.path.startsWith('/project/')
&& this.$route.path !== '/project/new') {
await this.$router.push({ path: '/project/new' });
}
},
@ -551,6 +567,17 @@ export default {
isAuthenticated() {
return document.cookie.includes('semaphore=');
},
templatesUrl() {
let viewId = localStorage.getItem(`project${this.projectId}__lastVisitedViewId`);
if (viewId) {
viewId = parseInt(viewId, 10);
if (!Number.isNaN(viewId)) {
return `/project/${this.projectId}/views/${viewId}/templates`;
}
}
return `/project/${this.projectId}/templates`;
},
},
async created() {
@ -700,8 +727,8 @@ export default {
// try to find project and switch to it if URL not pointing to any project
if (this.$route.path === '/'
|| this.$route.path === '/project'
|| (this.$route.path.startsWith('/project/'))) {
|| this.$route.path === '/project'
|| (this.$route.path.startsWith('/project/'))) {
await this.trySelectMostSuitableProject();
}
@ -729,7 +756,7 @@ export default {
}
if ((projectId == null || !this.projects.some((p) => p.id === projectId))
&& localStorage.getItem('projectId')) {
&& localStorage.getItem('projectId')) {
projectId = parseInt(localStorage.getItem('projectId'), 10);
}
@ -771,7 +798,7 @@ export default {
getProjectColor(projectData) {
const projectIndex = this.projects.length
- this.projects.findIndex((p) => p.id === projectData.id);
- this.projects.findIndex((p) => p.id === projectData.id);
return PROJECT_COLORS[projectIndex % PROJECT_COLORS.length];
},

View File

@ -13,7 +13,10 @@
<v-text-field
v-model="item.password"
label="Password"
:rules="[v => !!v || 'Email is required']"
:type="showPassword ? 'text' : 'password'"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="showPassword = !showPassword"
:rules="[v => !!v || 'Password is required']"
required
:disabled="formSaving"
></v-text-field>
@ -25,6 +28,12 @@ import ItemFormBase from '@/components/ItemFormBase';
export default {
mixins: [ItemFormBase],
data() {
return {
showPassword: false,
};
},
methods: {
async loadData() {
this.item = {};

View File

@ -10,13 +10,10 @@ Can use used in tandem with ItemFormBase.js. See KeyForm.vue for example.
persistent
:transition="false"
:content-class="'item-dialog item-dialog--' + position"
@keydown.esc="close()"
>
<v-card>
<v-card-title class="headline">
<slot
name="title"
>{{ title }}</slot>
<v-card-title>
<slot name="title">{{ title }}</slot>
</v-card-title>
<v-card-text class="pb-0">
@ -85,6 +82,11 @@ export default {
async dialog(val) {
this.$emit('input', val);
this.needReset = val;
if (val) {
window.addEventListener('keydown', this.handleEscape);
} else {
window.removeEventListener('keydown', this.handleEscape);
}
},
async value(val) {
@ -109,6 +111,12 @@ export default {
this.needSave = false;
this.needReset = false;
},
handleEscape(ev) {
if (ev.key === 'Escape' && this.dialog !== false) {
this.close();
}
},
},
};
</script>

View File

@ -0,0 +1,219 @@
<template>
<div v-if="views != null">
<draggable
v-if="views.length > 0"
:list="views"
handle=".handle6785"
class="mb-5"
@end="onDragEnd"
>
<div v-for="(view) in views" :key="view.id" class="d-flex mb-2">
<v-icon class="handle6785" style="cursor: move;">mdi-menu</v-icon>
<v-text-field
class="ml-4 mr-1"
hide-details
dense
solo
:flat="!view.active"
v-model="view.title"
@focus="editView(view.id)"
:disabled="view.disabled"
/>
<v-btn
class="mt-1"
small
icon
@click="saveView(view.id)"
v-if="view.active"
:disabled="view.disabled"
>
<v-icon small color="green">mdi-check</v-icon>
</v-btn>
<v-btn
class="mt-1"
small
icon
@click="resetView(view.id)"
v-if="view.active && view.id > 0"
:disabled="view.disabled"
>
<v-icon small color="red">mdi-close</v-icon>
</v-btn>
<v-btn class="ml-4" icon @click="removeView(view.id)">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
</draggable>
<v-alert
v-else
type="info"
>No views</v-alert>
<v-btn @click="addView()" color="primary">Add view</v-btn>
</div>
</template>
<script>
import draggable from 'vuedraggable';
import axios from 'axios';
export default {
props: {
projectId: Number,
},
components: {
draggable,
},
async created() {
this.views = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/views`,
responseType: 'json',
})).data.map((view) => ({
...view,
active: false,
disabled: false,
}));
this.views.sort((v1, v2) => v1.position - v2.position);
},
data() {
return {
views: null,
};
},
methods: {
async onDragEnd() {
const viewPositions = this.views.reduce((ret, view, index) => {
if (view.id < 0 || view.position === index) {
return ret;
}
return {
...ret,
[view.id]: index,
};
}, {});
await axios({
method: 'post',
url: `/api/project/${this.projectId}/views/positions`,
responseType: 'json',
data: viewPositions,
});
Object.keys(viewPositions).map((id) => parseInt(id, 10)).forEach((id) => {
const view = this.views.find((v) => v.id === id);
view.position = viewPositions[id];
});
},
async saveView(viewId) {
const i = this.views.findIndex((v) => v.id === viewId);
if (i === -1) {
return;
}
const view = this.views[i];
if (!view.title) {
return;
}
view.disabled = true;
try {
if (view.id < 0) {
const newView = (await axios({
method: 'post',
url: `/api/project/${this.projectId}/views`,
responseType: 'json',
data: {
project_id: this.projectId,
title: view.title,
position: i,
},
})).data;
view.id = newView.id;
} else {
await axios({
method: 'put',
url: `/api/project/${this.projectId}/views/${view.id}`,
responseType: 'json',
data: {
id: view.id,
project_id: this.projectId,
title: view.title,
position: i,
},
});
}
} finally {
view.disabled = false;
}
view.active = false;
},
async resetView(viewId) {
const view = this.views.find((v) => v.id === viewId);
if (view == null) {
return;
}
view.disabled = true;
try {
const oldView = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/views/${view.id}`,
responseType: 'json',
})).data;
view.title = oldView.title;
} finally {
view.disabled = false;
}
view.active = false;
},
editView(viewId) {
const view = this.views.find((v) => v.id === viewId);
if (view == null) {
return;
}
view.active = true;
},
async removeView(viewId) {
const i = this.views.findIndex((v) => v.id === viewId);
if (i === -1) {
return;
}
const view = this.views[i];
if (view.id >= 0) {
view.disabled = true;
try {
await axios({
method: 'delete',
url: `/api/project/${this.projectId}/views/${view.id}`,
responseType: 'json',
});
} finally {
view.disabled = false;
}
}
this.views.splice(i, 1);
},
addView() {
this.views.push({
id: -Math.round(Math.random() * 10000000),
title: '',
active: true,
disabled: false,
});
},
},
};
</script>

View File

@ -29,11 +29,12 @@
<v-alert
dense
text
type="info"
class="mt-4"
>
Must be valid JSON. You may use the key <code>ENV</code> to pass environment variables
to ansible-playbook.
Environment must be valid JSON. You may use the key <code>ENV</code> to pass
environment variables to ansible-playbook.
Example:
<pre style="font-size: 14px;">{
"var_available_in_playbook_1": 1245,

View File

@ -21,18 +21,18 @@
<v-select
v-model="item.ssh_key_id"
label="User Access Key"
label="User Credentials"
:items="keys"
item-value="id"
item-text="name"
:rules="[v => !!v || 'Access Key is required']"
:rules="[v => !!v || 'User Credentials is required']"
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.become_key_id"
label="Become User Access Key"
label="Sudo Credentials (Optional)"
clearable
:items="loginPasswordKeys"
item-value="id"
@ -70,6 +70,7 @@
<v-alert
dense
text
class="mt-4"
type="info"
v-if="item.type === 'static'"

View File

@ -65,6 +65,7 @@ export default {
methods: {
async reset() {
this.item = null;
this.formError = null;
if (this.$refs.form) {
this.$refs.form.resetValidation();
}
@ -79,6 +80,18 @@ export default {
throw new Error('Not implemented'); // must me implemented in template
},
beforeSave() {
},
afterSave() {
},
afterLoadData() {
},
getNewItem() {
return {};
},
@ -93,6 +106,8 @@ export default {
responseType: 'json',
})).data;
}
await this.afterLoadData();
},
/**
@ -129,6 +144,8 @@ export default {
let item;
try {
await this.beforeSave();
item = (await axios({
method: this.isNew ? 'post' : 'put',
url: this.isNew
@ -142,6 +159,8 @@ export default {
...(this.getRequestOptions()),
})).data;
await this.afterSave(item);
this.$emit('save', {
item: item || this.item,
action: this.isNew ? 'new' : 'edit',

View File

@ -26,10 +26,14 @@ export default {
},
async created() {
await this.beforeLoadItems();
await this.loadItems();
},
methods: {
// eslint-disable-next-line no-empty-function
async beforeLoadItems() { },
getSingleItemUrl() {
throw new Error('Not implemented');
},

View File

@ -18,25 +18,25 @@
:rules="[v => !!v || 'Name is required']"
required
:disabled="formSaving"
></v-text-field>
/>
<v-select
v-model="item.type"
label="Type"
:rules="[v => !!v || 'Type is required']"
:rules="[v => (!!v || !canEditSecrets) || 'Type is required']"
:items="inventoryTypes"
item-value="id"
item-text="name"
required
:required="canEditSecrets"
:disabled="formSaving || !canEditSecrets"
></v-select>
/>
<v-text-field
v-model="item.login_password.passphrase"
label="Passphrase (Optional)"
v-if="item.type === 'ssh'"
:disabled="formSaving || !canEditSecrets"
></v-text-field>
/>
<v-textarea
outlined
@ -45,33 +45,34 @@
:disabled="formSaving || !canEditSecrets"
:rules="[v => !!v || 'Private Key is required']"
v-if="item.type === 'ssh'"
></v-textarea>
/>
<v-text-field
v-model="item.login_password.login"
label="Login (Optional)"
v-if="item.type === 'login_password'"
:disabled="formSaving || !canEditSecrets"
></v-text-field>
/>
<v-text-field
v-model="item.login_password.password"
label="Password"
:rules="[v => !!v || 'Password is required']"
:rules="[v => (!!v || !canEditSecrets) || 'Password is required']"
v-if="item.type === 'login_password'"
required
:required="canEditSecrets"
:disabled="formSaving || !canEditSecrets"
autocomplete="new-password"
></v-text-field>
/>
<v-checkbox
v-model="item.override_secret"
label="Override"
v-if="!isNew"
></v-checkbox>
/>
<v-alert
dense
text
type="info"
v-if="item.type === 'none'"
>

View File

@ -23,11 +23,11 @@
<v-text-field
v-model="item.git_url"
label="Git URL"
append-outer-icon="mdi-help-circle"
:rules="[v => !!v || 'Repository is required']"
required
:disabled="formSaving"
@click:append-outer="showGitUrlHelp()"
append-outer-icon="mdi-help-circle"
@click:append-outer="showHelpDialog('url')"
></v-text-field>
<v-select
@ -40,11 +40,11 @@
:rules="[v => !!v || 'Key is required']"
required
:disabled="formSaving"
@click:append-outer="showKeyHelp()"
@click:append-outer="showHelpDialog('key')"
></v-select>
<v-dialog
v-model="gitUrlHelpDialog"
v-model="helpDialog"
hide-overlay
width="300"
>
@ -55,10 +55,15 @@
elevation="2"
class="mb-0"
>
<p><b>Git URL</b> can be SSH (git@***) or HTTPS (https://***) URL.</p>
<p>If you use SSH URL you should specify <b>Access Key</b> with type <code>SSH</code>.</p>
<p>If you use HTTPS URL you should specify <b>Access Key</b> with type
<code>None</code>.</p>
<p v-if="helpKey === 'url'">Git or SSH URL of the repository
with your Ansible playbooks.</p>
<div v-else-if="helpKey === 'key'">
<p>Credentials to access to the Git repository. It should be:</p>
<ul>
<li><code>SSH</code> if you use SSH URL.</li>
<li><code>None</code> if you use HTTPS URL without authentication.</li>
</ul>
</div>
</v-alert>
</v-dialog>
</v-form>
@ -71,8 +76,8 @@ export default {
mixins: [ItemFormBase],
data() {
return {
gitUrlHelpDialog: false,
keyHelpDialog: false,
helpDialog: null,
helpKey: null,
keys: null,
inventoryTypes: [{
@ -92,12 +97,9 @@ export default {
})).data;
},
methods: {
showGitUrlHelp() {
this.gitUrlHelpDialog = true;
},
showKeyHelp() {
this.gitUrlHelpDialog = true;
showHelpDialog(key) {
this.helpKey = key;
this.helpDialog = true;
},
getItemsUrl() {

View File

@ -1,34 +1,49 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="item != null"
ref="form"
lazy-validation
v-model="formValid"
v-if="isLoaded()"
>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}</v-alert>
:value="formError"
color="error"
class="pb-2"
>{{ formError }}
</v-alert>
<v-textarea
outlined
class="mt-4"
v-model="item.environment"
label="Environment Override"
placeholder='Example: {"version": 10, "author": "John"}'
:disabled="formSaving"
rows="4"
></v-textarea>
<v-alert
color="blue"
dark
icon="mdi-source-fork"
dismissible
v-model="commitAvailable"
prominent
>
<div
style="font-weight: bold;"
>{{ commitHash ? commitHash.substr(0, 10) : '' }}
</div>
<div v-if="commitMessage">{{ commitMessage }}</div>
</v-alert>
<v-textarea
outlined
v-model="item.arguments"
label="Extra CLI Arguments"
:disabled="formSaving"
placeholder='Example: ["-i", "@myinventory.sh", "--private-key=/there/id_rsa", "-vvvv"]'
rows="4"
></v-textarea>
<v-select
v-if="template.type === 'deploy'"
v-model="item.build_task_id"
label="Build Version"
:items="buildTasks"
item-value="id"
:item-text="(itm) => itm.version + (itm.message ? ' — ' + itm.message : '')"
:rules="[v => !!v || 'Build Version is required']"
required
:disabled="formSaving"
/>
<v-text-field
v-model="item.message"
label="Message (Optional)"
:disabled="formSaving"
/>
<v-row no-gutters>
<v-col>
@ -49,11 +64,22 @@
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
import axios from 'axios';
export default {
mixins: [ItemFormBase],
props: {
templateId: Number,
commitHash: String,
commitMessage: String,
buildTask: Object,
},
data() {
return {
template: null,
buildTasks: null,
commitAvailable: null,
};
},
watch: {
needReset(val) {
@ -65,11 +91,50 @@ export default {
templateId(val) {
this.item.template_id = val;
},
commitHash(val) {
this.item.commit_hash = val;
this.commitAvailable = this.item.commit_hash != null;
},
version(val) {
this.item.version = val;
},
commitAvailable(val) {
this.item.commit_hash = val ? this.commitHash : null;
},
},
created() {
this.item.template_id = this.templateId;
},
methods: {
isLoaded() {
return this.item != null
&& this.template != null
&& this.buildTasks != null;
},
async afterLoadData() {
this.item.template_id = this.templateId;
this.template = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates/${this.templateId}`,
responseType: 'json',
})).data;
this.buildTasks = this.template.type === 'deploy' ? (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates/${this.template.build_template_id}/tasks`,
responseType: 'json',
})).data.filter((task) => task.version != null && task.status === 'success') : [];
if (this.buildTasks.length > 0) {
this.item.build_task_id = this.build_task ? this.build_task.id : this.buildTasks[0].id;
}
this.commitAvailable = this.commitHash != null;
},
getItemsUrl() {
return `/api/project/${this.projectId}/tasks`;
},

View File

@ -0,0 +1,90 @@
<template>
<span>
<v-icon
v-if="status != null"
small
class="mr-1"
:color="statusColor"
>mdi-{{ statusIcon }}
</v-icon>
<span v-if="disabled">{{ label }}</span>
<v-tooltip
v-else
color="black"
right
max-width="350"
transition="fade-transition"
:disabled="!tooltip"
>
<template v-slot:activator="{ on, attrs }">
<a
v-bind="attrs"
v-on="on"
@click="showTaskLog()"
:class="{'task-link-with-tooltip': tooltip}"
>{{ label }}</a>
</template>
<span>{{ tooltip }}</span>
</v-tooltip>
</span>
</template>
<style lang="scss">
@import '~vuetify/src/styles/settings/_colors.scss';
.task-link-with-tooltip {
text-decoration: underline !important;
text-decoration-style: dashed !important;
text-decoration-color: gray !important;
}
a.task-link-with-tooltip {
&:hover {
text-decoration-style: solid !important;
text-decoration-color: map-deep-get($blue, 'darken-2') !important;
}
}
</style>
<script>
import EventBus from '@/event-bus';
export default {
props: {
label: String,
tooltip: String,
taskId: Number,
disabled: Boolean,
status: String,
},
computed: {
statusColor() {
switch (this.status) {
case 'success':
return 'success';
case 'error':
return 'red';
default:
return 'gray';
}
},
statusIcon() {
switch (this.status) {
case 'success':
return 'check';
case 'error':
return 'close';
default:
return 'clock-time-three-outline';
}
},
},
methods: {
showTaskLog() {
EventBus.$emit('i-show-task', {
taskId: this.taskId,
});
},
},
};
</script>

View File

@ -0,0 +1,178 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="tasks != null">
<EditDialog
v-model="newTaskDialog"
:save-button-text="'Re' + getActionButtonTitle()"
@save="onTaskCreated"
>
<template v-slot:title={}>
<v-icon class="mr-4">{{ TEMPLATE_TYPE_ICONS[template.type] }}</v-icon>
<span class="breadcrumbs__item">{{ template.alias }}</span>
<v-icon>mdi-chevron-right</v-icon>
<span class="breadcrumbs__item">New Task</span>
</template>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<TaskForm
:project-id="template.project_id"
item-id="new"
:template-id="template.id"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
:commit-hash="sourceTask == null ? null : sourceTask.commit_hash"
:commit-message="sourceTask == null ? null : sourceTask.commit_message"
:build_task="sourceTask == null ? null : sourceTask.build_task"
/>
</template>
</EditDialog>
<v-data-table
:headers="headers"
:items="tasks"
:hide-default-footer="hideFooter"
:footer-props="{ itemsPerPageOptions: [20] }"
class="mt-0"
>
<template v-slot:item.id="{ item }">
<TaskLink
:task-id="item.id"
:tooltip="item.message"
:label="'#' + item.id"
/>
</template>
<template v-slot:item.version="{ item }">
<div v-if="item.tpl_type !== ''">
<TaskLink
:disabled="item.tpl_type === 'build'"
:task-id="item.build_task_id"
:tooltip="item.tpl_type === 'build' ? item.message : item.build_task.message"
:label="item.tpl_type === 'build' ? item.version : item.build_task.version"
:status="item.status"
/>
</div>
<div v-else>&mdash;</div>
</template>
<template v-slot:item.status="{ item }">
<TaskStatus :status="item.status"/>
</template>
<template v-slot:item.start="{ item }">
{{ item.start | formatDate }}
</template>
<template v-slot:item.end="{ item }">
{{ [item.start, item.end] | formatMilliseconds }}
</template>
<template v-slot:item.actions="{ item }">
<v-btn text color="black" class="pl-1 pr-2" @click="createTask(item)">
<v-icon class="pr-1">mdi-replay</v-icon>
Re{{ getActionButtonTitle() }}
</v-btn>
</template>
</v-data-table>
</div>
</template>
<script>
import axios from 'axios';
import EventBus from '@/event-bus';
import TaskForm from '@/components/TaskForm.vue';
import TaskStatus from '@/components/TaskStatus.vue';
import TaskLink from '@/components/TaskLink.vue';
import EditDialog from '@/components/EditDialog.vue';
import { TEMPLATE_TYPE_ACTION_TITLES, TEMPLATE_TYPE_ICONS } from '@/lib/constants';
export default {
components: {
EditDialog, TaskStatus, TaskForm, TaskLink,
},
props: {
template: Object,
limit: Number,
hideFooter: Boolean,
},
data() {
return {
headers: [
{
text: 'Task ID',
value: 'id',
sortable: false,
},
{
text: 'Version',
value: 'version',
sortable: false,
},
{
text: 'Status',
value: 'status',
sortable: false,
},
{
text: 'User',
value: 'user_name',
sortable: false,
},
{
text: 'Start',
value: 'start',
sortable: false,
},
{
text: 'Duration',
value: 'end',
sortable: false,
},
{
text: 'Actions',
value: 'actions',
sortable: false,
width: '0%',
},
],
tasks: null,
taskId: null,
newTaskDialog: null,
sourceTask: null,
TEMPLATE_TYPE_ICONS,
};
},
watch: {
async template() {
await this.loadData();
},
},
async created() {
await this.loadData();
},
methods: {
async loadData() {
this.tasks = null;
this.tasks = (await axios({
method: 'get',
url: `/api/project/${this.template.project_id}/templates/${this.template.id}/tasks/last?limit=${this.limit || 200}`,
responseType: 'json',
})).data;
},
getActionButtonTitle() {
return TEMPLATE_TYPE_ACTION_TITLES[this.template.type];
},
onTaskCreated(e) {
EventBus.$emit('i-show-task', {
taskId: e.item.id,
});
},
createTask(task) {
this.sourceTask = task;
this.newTaskDialog = true;
},
},
};
</script>

View File

@ -1,5 +1,12 @@
<template>
<div>
<div class="task-log-view" :class="{'task-log-view--with-message': item.message}">
<v-alert
type="info"
text
v-if="item.message"
>{{ item.message }}
</v-alert>
<v-container class="pa-0 mb-2">
<v-row no-gutters>
<v-col>
@ -7,21 +14,21 @@
<v-list-item class="pa-0">
<v-list-item-content>
<div>
<TaskStatus :status="item.status" />
<TaskStatus :status="item.status"/>
</div>
</v-list-item-content>
</v-list-item>
</v-list>
</v-col>
<v-col>
<v-list two-line subheader class="pa-0">
<v-list-item class="pa-0">
<v-list-item-content>
<v-list-item-title>Author</v-list-item-title>
<v-list-item-subtitle>{{ user.name }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
<v-list two-line subheader class="pa-0">
<v-list-item class="pa-0">
<v-list-item-content>
<v-list-item-title>Author</v-list-item-title>
<v-list-item-subtitle>{{ user.name }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-col>
<v-col>
<v-list two-line subheader class="pa-0">
@ -47,12 +54,12 @@
</v-col>
</v-row>
</v-container>
<div class="task-log-view" ref="output">
<div class="task-log-view__record" v-for="record in output" :key="record.id">
<div class="task-log-view__time">
<div class="task-log-records" ref="output">
<div class="task-log-records__record" v-for="record in output" :key="record.id">
<div class="task-log-records__time">
{{ record.time | formatTime }}
</div>
<div class="task-log-view__output">{{ record.output }}</div>
<div class="task-log-records__output">{{ record.output }}</div>
</div>
</div>
@ -66,31 +73,52 @@
</v-btn>
</div>
</template>
<style lang="scss">
.task-log-view {
background: black;
color: white;
height: calc(100vh - 300px);
overflow: auto;
font-family: monospace;
margin: 0 -24px;
padding: 5px 10px;
@import '~vuetify/src/styles/settings/_variables';
.task-log-view {
}
.task-log-records {
background: black;
color: white;
height: calc(100vh - 250px);
overflow: auto;
font-family: monospace;
margin: 0 -24px;
padding: 5px 10px;
}
.task-log-view--with-message .task-log-records {
height: calc(100vh - 300px);
}
.task-log-records__record {
display: flex;
flex-direction: row;
justify-content: left;
}
.task-log-records__time {
width: 120px;
min-width: 120px;
}
.task-log-records__output {
width: 100%;
}
@media #{map-get($display-breakpoints, 'sm-and-down')} {
.task-log-records {
height: calc(100vh - 340px);
}
.task-log-view__record {
display: flex;
flex-direction: row;
justify-content: left;
}
.task-log-view__time {
width: 120px;
min-width: 120px;
}
.task-log-view__output {
width: 100%;
.task-log-view--with-message .task-log-records {
height: calc(100vh - 370px);
}
}
</style>
<script>
import axios from 'axios';

View File

@ -1,30 +1,133 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="isLoaded"
ref="form"
lazy-validation
v-model="formValid"
v-if="isLoaded"
>
<v-row>
<v-col cols="12" md="6" class="pb-0">
<v-text-field
<v-dialog
v-model="helpDialog"
hide-overlay
width="300"
>
<v-alert
border="top"
colored-border
type="info"
elevation="2"
class="mb-0 pb-0"
>
<div v-if="helpKey === 'build_version'">
<p>
Defines start version of your
<a target="_black" href="https://en.wikipedia.org/wiki/Software_build">artifact</a>.
Each run increments the artifact version.
</p>
<p>
For more information about building, see the
<a href="https://docs.ansible-semaphore.com/user-guide/task-templates#build"
target="_blank"
>Task Template reference</a>.
</p>
</div>
<div v-else-if="helpKey === 'build'">
<p>
Defines what
<a target="_black" href="https://en.wikipedia.org/wiki/Software_build">artifact</a>
should be deployed when the task run.
</p>
<p>
For more information about deploying, see the
<a href="https://docs.ansible-semaphore.com/user-guide/task-templates#build"
target="_blank"
>Task Template reference</a>.
</p>
</div>
<div v-if="helpKey === 'cron'">
<p>Defines autorun schedule.</p>
<p>
For more information about cron, see the
<a href="https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format"
target="_blank"
>Cron expression format reference</a>.
</p>
</div>
</v-alert>
</v-dialog>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}
</v-alert>
<v-row>
<v-col cols="12" md="6" class="pb-0">
<v-card class="mb-6">
<v-tabs
fixed-tabs
v-model="itemTypeIndex"
>
<v-tab
style="padding: 0"
v-for="(key) in Object.keys(TEMPLATE_TYPE_ICONS)"
:key="key"
>
<v-icon small class="mr-2">{{ TEMPLATE_TYPE_ICONS[key] }}</v-icon>
{{ TEMPLATE_TYPE_TITLES[key] }}
</v-tab>
</v-tabs>
<div class="ml-4 mr-4 mt-6" v-if="item.type">
<v-text-field
v-if="item.type === 'build'"
v-model="item.start_version"
label="Start Version"
:rules="[v => !!v || 'Start Version is required']"
required
:disabled="formSaving"
placeholder="Example: 0.0.0"
append-outer-icon="mdi-help-circle"
@click:append-outer="showHelpDialog('build_version')"
></v-text-field>
<v-select
v-if="item.type === 'deploy'"
v-model="item.build_template_id"
label="Build Template"
:items="buildTemplates"
item-value="id"
item-text="alias"
:rules="[v => !!v || 'Build Template is required']"
required
:disabled="formSaving"
append-outer-icon="mdi-help-circle"
@click:append-outer="showHelpDialog('build')"
></v-select>
</div>
</v-card>
<v-text-field
v-model="item.alias"
label="Playbook Alias"
:rules="[v => !!v || 'Playbook Alias is required']"
required
:disabled="formSaving"
></v-text-field>
></v-text-field>
<v-text-field
<v-text-field
v-model="item.playbook"
label="Playbook Filename"
:rules="[v => !!v || 'Playbook Filename is required']"
required
:disabled="formSaving"
placeholder="Example: site.yml"
></v-text-field>
></v-text-field>
<v-select
<v-select
v-model="item.inventory_id"
label="Inventory"
:items="inventory"
@ -33,52 +136,50 @@
:rules="[v => !!v || 'Inventory is required']"
required
:disabled="formSaving"
></v-select>
></v-select>
<v-select
v-model="item.repository_id"
label="Playbook Repository"
:items="repositories"
item-value="id"
item-text="name"
:rules="[v => !!v || 'Playbook Repository is required']"
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.repository_id"
label="Playbook Repository"
:items="repositories"
item-value="id"
item-text="name"
:rules="[v => !!v || 'Playbook Repository is required']"
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.environment_id"
label="Environment"
:items="environment"
item-value="id"
item-text="name"
:rules="[v => !!v || 'Environment is required']"
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.environment_id"
label="Environment"
:items="environment"
item-value="id"
item-text="name"
:rules="[v => !!v || 'Environment is required']"
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.vault_key_id"
label="Vault Password"
clearable
:items="loginPasswordKeys"
item-value="id"
item-text="name"
:disabled="formSaving"
></v-select>
</v-col>
<v-select
v-model="item.vault_pass_id"
label="Vault Password"
clearable
:items="loginPasswordKeys"
item-value="id"
item-text="name"
:disabled="formSaving"
></v-select>
<v-col cols="12" md="6" class="pb-0">
<v-textarea
outlined
v-model="item.description"
label="Description"
:disabled="formSaving"
rows="5"
></v-textarea>
</v-col>
<v-col cols="12" md="6" class="pb-0">
<v-textarea
outlined
v-model="item.description"
label="Description"
:disabled="formSaving"
rows="5"
></v-textarea>
<codemirror
<codemirror
:style="{ border: '1px solid lightgray' }"
v-model="item.arguments"
:options="cmOptions"
@ -91,9 +192,30 @@ Example:
"--private-key=/there/id_rsa",
"-vvvv"
]'
/>
</v-col>
</v-row>
/>
<v-select
v-model="item.view_id"
label="View"
clearable
:items="views"
item-value="id"
item-text="title"
:disabled="formSaving"
></v-select>
<v-text-field
class="mt-6"
v-model="cronFormat"
label="Cron"
:disabled="formSaving"
placeholder="Example: * 1 * * * *"
v-if="schedules == null || schedules.length <= 1"
append-outer-icon="mdi-help-circle"
@click:append-outer="showHelpDialog('cron')"
></v-text-field>
</v-col>
</v-row>
</v-form>
</template>
<script>
@ -107,6 +229,7 @@ import 'codemirror/lib/codemirror.css';
import 'codemirror/mode/vue/vue.js';
// import 'codemirror/addon/lint/json-lint.js';
import 'codemirror/addon/display/placeholder.js';
import { TEMPLATE_TYPE_ICONS, TEMPLATE_TYPE_TITLES } from '../lib/constants';
export default {
mixins: [ItemFormBase],
@ -121,6 +244,9 @@ export default {
data() {
return {
itemTypeIndex: 0,
TEMPLATE_TYPE_ICONS,
TEMPLATE_TYPE_TITLES,
cmOptions: {
tabSize: 2,
mode: 'application/json',
@ -134,49 +260,30 @@ export default {
inventory: null,
repositories: null,
environment: null,
schedules: null,
views: null,
cronFormat: null,
helpDialog: null,
helpKey: null,
};
},
watch: {
needReset(val) {
if (val) {
this.item.template_id = this.templateId;
if (this.item != null) {
this.item.template_id = this.templateId;
}
}
},
sourceItemId(val) {
this.item.template_id = val;
},
},
async created() {
if (this.sourceItemId) {
this.item = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates/${this.sourceItemId}`,
responseType: 'json',
})).data;
}
this.keys = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/keys`,
responseType: 'json',
})).data;
this.repositories = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/repositories`,
responseType: 'json',
})).data;
this.inventory = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/inventory`,
responseType: 'json',
})).data;
this.environment = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/environment`,
responseType: 'json',
})).data;
itemTypeIndex(val) {
this.item.type = Object.keys(TEMPLATE_TYPE_ICONS)[val];
},
},
computed: {
@ -186,10 +293,12 @@ export default {
}
return this.keys != null
&& this.repositories != null
&& this.inventory != null
&& this.environment != null
&& this.item != null;
&& this.repositories != null
&& this.inventory != null
&& this.environment != null
&& this.item != null
&& this.schedules != null
&& this.views != null;
},
loginPasswordKeys() {
@ -201,6 +310,69 @@ export default {
},
methods: {
showHelpDialog(key) {
this.helpKey = key;
this.helpDialog = true;
},
async afterLoadData() {
if (this.sourceItemId) {
this.item = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates/${this.sourceItemId}`,
responseType: 'json',
})).data;
}
this.keys = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/keys`,
responseType: 'json',
})).data;
this.repositories = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/repositories`,
responseType: 'json',
})).data;
this.inventory = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/inventory`,
responseType: 'json',
})).data;
this.environment = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/environment`,
responseType: 'json',
})).data;
this.buildTemplates = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates?type=build`,
responseType: 'json',
})).data.filter((template) => template.type === 'build');
this.schedules = this.isNew ? [] : (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/templates/${this.itemId}/schedules`,
responseType: 'json',
})).data;
this.views = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/views`,
responseType: 'json',
})).data;
if (this.schedules.length === 1) {
this.cronFormat = this.schedules[0].cron_format;
}
this.itemTypeIndex = Object.keys(TEMPLATE_TYPE_ICONS).indexOf(this.item.type);
},
getItemsUrl() {
return `/api/project/${this.projectId}/templates`;
},
@ -208,6 +380,61 @@ export default {
getSingleItemUrl() {
return `/api/project/${this.projectId}/templates/${this.itemId}`;
},
async beforeSave() {
if (this.cronFormat == null || this.cronFormat === '') {
return;
}
await axios({
method: 'post',
url: `/api/project/${this.projectId}/schedules/validate`,
responseType: 'json',
data: {
cron_format: this.cronFormat,
},
});
},
async afterSave(newItem) {
if (newItem || this.schedules.length === 0) {
if (this.cronFormat != null && 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.cronFormat == null || this.cronFormat === '') {
// 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: {
id: this.schedules[0].id,
project_id: this.projectId,
template_id: this.itemId,
cron_format: this.cronFormat,
},
});
}
},
},
};
</script>

17
web2/src/lib/constants.js Normal file
View File

@ -0,0 +1,17 @@
export const TEMPLATE_TYPE_ICONS = {
'': 'mdi-cog',
build: 'mdi-wrench',
deploy: 'mdi-rocket-launch',
};
export const TEMPLATE_TYPE_TITLES = {
'': 'Task',
build: 'Build',
deploy: 'Deploy',
};
export const TEMPLATE_TYPE_ACTION_TITLES = {
'': 'Run',
build: 'Build',
deploy: 'Deploy',
};

View File

@ -41,6 +41,10 @@ const routes = [
path: '/project/:projectId/templates',
component: Templates,
},
{
path: '/project/:projectId/views/:viewId/templates',
component: Templates,
},
{
path: '/project/:projectId/templates/:templateId',
component: TemplateView,

View File

@ -13,18 +13,55 @@
</div>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
:footer-props="{ itemsPerPageOptions: [20] }"
class="mt-4"
:headers="headers"
:items="items"
:footer-props="{ itemsPerPageOptions: [20] }"
class="mt-4"
>
<template v-slot:item.tpl_alias="{ item }">
<a @click="showTaskLog(item.id)">{{ item.tpl_alias }}</a>
<span style="color: gray; margin-left: 10px;">#{{ item.id }}</span>
<div class="d-flex">
<v-icon class="mr-3" small>
{{ TEMPLATE_TYPE_ICONS[item.tpl_type] }}
</v-icon>
<TaskLink
:task-id="item.id"
:tooltip="item.message"
:label="'#' + item.id"
/>
<v-icon small class="ml-1 mr-1">mdi-arrow-left</v-icon>
<a :href="
'/project/' + item.project_id +
'/templates/' + item.template_id"
>{{ item.tpl_alias }}</a>
</div>
</template>
<template v-slot:item.version="{ item }">
<TaskLink
:disabled="item.tpl_type === 'build'"
class="ml-2"
v-if="item.tpl_type !== ''"
:status="item.status"
:task-id="item.tpl_type === 'build'
? item.id
: item.build_task.id"
:label="item.tpl_type === 'build'
? item.version
: item.build_task.version"
:tooltip="item.tpl_type === 'build'
? item.message
: item.build_task.message"
/>
<div class="text-center" v-else>&mdash;</div>
</template>
<template v-slot:item.status="{ item }">
<TaskStatus :status="item.status" />
<TaskStatus :status="item.status"/>
</template>
<template v-slot:item.start="{ item }">
@ -42,12 +79,18 @@
import ItemListPageBase from '@/components/ItemListPageBase';
import EventBus from '@/event-bus';
import TaskStatus from '@/components/TaskStatus.vue';
import TaskLink from '@/components/TaskLink.vue';
import socket from '@/socket';
import { TEMPLATE_TYPE_ICONS } from '@/lib/constants';
export default {
mixins: [ItemListPageBase],
components: { TaskStatus },
data() {
return { TEMPLATE_TYPE_ICONS };
},
components: { TaskStatus, TaskLink },
watch: {
async projectId() {
@ -90,6 +133,11 @@ export default {
value: 'tpl_alias',
sortable: false,
},
{
text: 'Version',
value: 'version',
sortable: false,
},
{
text: 'Status',
value: 'status',

View File

@ -1,63 +1,65 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="!isLoaded">
<v-progress-linear
indeterminate
color="primary darken-2"
indeterminate
color="primary darken-2"
></v-progress-linear>
</div>
<div v-else>
<EditDialog
max-width="700"
v-model="editDialog"
save-button-text="Save"
title="Edit Template"
@save="loadData()"
:max-width="700"
v-model="editDialog"
save-button-text="Save"
title="Edit Template"
@save="loadData()"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<TemplateForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</EditDialog>
<EditDialog
v-model="copyDialog"
save-button-text="Create"
title="New Template"
@save="onTemplateCopied"
v-model="copyDialog"
save-button-text="Create"
title="New Template"
@save="onTemplateCopied"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<TemplateForm
:project-id="projectId"
item-id="new"
:source-item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
:project-id="projectId"
item-id="new"
:source-item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</EditDialog>
<YesNoDialog
title="Delete template"
text="Are you really want to delete this template?"
v-model="deleteDialog"
@yes="remove()"
title="Delete template"
text="Are you really want to delete this template?"
v-model="deleteDialog"
@yes="remove()"
/>
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title class="breadcrumbs">
<router-link
class="breadcrumbs__item breadcrumbs__item--link"
:to="`/project/${projectId}/templates/`"
>Task Templates</router-link>
class="breadcrumbs__item breadcrumbs__item--link"
:to="`/project/${projectId}/templates/`"
>Task Templates
</router-link>
<v-icon>mdi-chevron-right</v-icon>
<span class="breadcrumbs__item">{{ item.alias }}</span>
</v-toolbar-title>
@ -65,45 +67,48 @@
<v-spacer></v-spacer>
<v-btn
icon
color="error"
@click="deleteDialog = true"
icon
color="error"
@click="deleteDialog = true"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
<v-btn
icon
color="black"
@click="copyDialog = true"
icon
color="black"
@click="copyDialog = true"
>
<v-icon>mdi-content-copy</v-icon>
</v-btn>
<v-btn
icon
color="black"
@click="editDialog = true"
icon
color="black"
@click="editDialog = true"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</v-toolbar>
<v-container class="pa-0">
<v-container>
<v-alert
border="top"
colored-border
text
type="info"
elevation="2"
class="mb-0 ml-4 mr-4 mb-2"
v-if="item.description"
>{{ item.description }}</v-alert>
>{{ item.description }}
</v-alert>
<v-row>
<v-col>
<v-list two-line subheader>
<v-list subheader dense>
<v-list-item>
<v-list-item-icon>
<v-icon>mdi-book-play</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Playbook</v-list-item-title>
<v-list-item-subtitle>{{ item.playbook }}</v-list-item-subtitle>
@ -112,7 +117,21 @@
</v-list>
</v-col>
<v-col>
<v-list two-line subheader>
<v-list subheader dense>
<v-list-item>
<v-list-item-icon>
<v-icon>{{ TEMPLATE_TYPE_ICONS[item.type] }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Type</v-list-item-title>
<v-list-item-subtitle>{{ TEMPLATE_TYPE_TITLES[item.type] }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-col>
<v-col>
<v-list subheader dense>
<v-list-item>
<v-list-item-icon>
<v-icon>mdi-monitor</v-icon>
@ -128,7 +147,7 @@
</v-list>
</v-col>
<v-col>
<v-list two-line subheader>
<v-list subheader dense>
<v-list-item>
<v-list-item-icon>
<v-icon>mdi-code-braces</v-icon>
@ -143,7 +162,7 @@
</v-list>
</v-col>
<v-col>
<v-list two-line subheader>
<v-list subheader dense>
<v-list-item>
<v-list-item-icon>
<v-icon>mdi-git</v-icon>
@ -160,28 +179,7 @@
</v-row>
</v-container>
<v-data-table
:headers="headers"
:items="tasks"
:footer-props="{ itemsPerPageOptions: [20] }"
class="mt-0"
>
<template v-slot:item.id="{ item }">
<a @click="showTaskLog(item.id)">#{{ item.id }}</a>
</template>
<template v-slot:item.status="{ item }">
<TaskStatus :status="item.status" />
</template>
<template v-slot:item.start="{ item }">
{{ item.start | formatDate }}
</template>
<template v-slot:item.end="{ item }">
{{ [item.start, item.end] | formatMilliseconds }}
</template>
</v-data-table>
<TaskList :template="item" />
</div>
</template>
<style lang="scss">
@ -194,11 +192,12 @@ import { getErrorMessage } from '@/lib/error';
import YesNoDialog from '@/components/YesNoDialog.vue';
import EditDialog from '@/components/EditDialog.vue';
import TemplateForm from '@/components/TemplateForm.vue';
import TaskStatus from '@/components/TaskStatus.vue';
import TaskList from '@/components/TaskList.vue';
import { TEMPLATE_TYPE_ACTION_TITLES, TEMPLATE_TYPE_ICONS, TEMPLATE_TYPE_TITLES } from '@/lib/constants';
export default {
components: {
YesNoDialog, EditDialog, TemplateForm, TaskStatus,
YesNoDialog, EditDialog, TemplateForm, TaskList,
},
props: {
@ -207,34 +206,6 @@ export default {
data() {
return {
headers: [
{
text: 'Task ID',
value: 'id',
sortable: false,
},
{
text: 'Status',
value: 'status',
sortable: false,
},
{
text: 'User',
value: 'user_name',
sortable: false,
},
{
text: 'Start',
value: 'start',
sortable: false,
},
{
text: 'Duration',
value: 'end',
sortable: false,
},
],
tasks: null,
item: null,
inventory: null,
environment: null,
@ -242,13 +213,17 @@ export default {
deleteDialog: null,
editDialog: null,
copyDialog: null,
taskLogDialog: null,
taskId: null,
TEMPLATE_TYPE_ICONS,
TEMPLATE_TYPE_TITLES,
TEMPLATE_TYPE_ACTION_TITLES,
};
},
computed: {
itemId() {
if (/^-?\d+$/.test(this.$route.params.templateId)) {
return parseInt(this.$route.params.templateId, 10);
}
return this.$route.params.templateId;
},
isNew() {
@ -256,10 +231,9 @@ export default {
},
isLoaded() {
return this.item
&& this.tasks
&& this.inventory
&& this.environment
&& this.repositories;
&& this.inventory
&& this.environment
&& this.repositories;
},
},
@ -280,12 +254,6 @@ export default {
},
methods: {
showTaskLog(taskId) {
EventBus.$emit('i-show-task', {
taskId,
});
},
showDrawer() {
EventBus.$emit('i-show-drawer');
},
@ -329,12 +297,6 @@ export default {
responseType: 'json',
})).data;
this.tasks = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/templates/${this.itemId}/tasks/last`,
responseType: 'json',
})).data;
this.inventory = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/inventory`,

View File

@ -1,37 +1,58 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="!isLoaded">
<v-progress-linear
indeterminate
color="primary darken-2"
indeterminate
color="primary darken-2"
></v-progress-linear>
</div>
<div v-else>
<v-dialog
v-model="editViewsDialog"
:max-width="400"
persistent
:transition="false"
>
<v-card>
<v-card-title>
Edit Views
<v-spacer></v-spacer>
<v-btn icon @click="closeEditViewDialog()">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<EditViewsForm :project-id="projectId"/>
</v-card-text>
</v-card>
</v-dialog>
<EditDialog
max-width="700"
v-model="editDialog"
save-button-text="Create"
title="New template"
@save="loadItems()"
:max-width="700"
v-model="editDialog"
save-button-text="Create"
title="New template"
@save="loadItems()"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<TemplateForm
:project-id="projectId"
item-id="new"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
:project-id="projectId"
item-id="new"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</EditDialog>
<EditDialog
v-model="newTaskDialog"
save-button-text="Run"
title="New Task"
@save="onTaskCreated"
v-model="newTaskDialog"
:save-button-text="TEMPLATE_TYPE_ACTION_TITLES[templateType]"
title="New Task"
@save="onTaskCreated"
>
<template v-slot:title={}>
<v-icon small class="mr-4">{{ TEMPLATE_TYPE_ICONS[templateType] }}</v-icon>
<span class="breadcrumbs__item">{{ templateAlias }}</span>
<v-icon>mdi-chevron-right</v-icon>
<span class="breadcrumbs__item">New Task</span>
@ -39,13 +60,13 @@
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<TaskForm
:project-id="projectId"
item-id="new"
:template-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
:project-id="projectId"
item-id="new"
:template-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</EditDialog>
@ -55,27 +76,96 @@
<v-toolbar-title>Task Templates</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="editItem('new')"
class="mr-1"
>New template</v-btn>
color="primary"
@click="editItem('new')"
class="mr-1"
>New template
</v-btn>
<v-btn icon @click="settingsSheet = true"><v-icon>mdi-cog</v-icon></v-btn>
<v-btn icon @click="settingsSheet = true">
<v-icon>mdi-cog</v-icon>
</v-btn>
</v-toolbar>
<v-tabs show-arrows class="pl-4" v-model="viewTab">
<v-tab :to="getViewUrl(null)" :disabled="viewItemsLoading">All</v-tab>
<v-tab
v-for="(view) in views"
:key="view.id"
:to="getViewUrl(view.id)"
:disabled="viewItemsLoading"
>{{ view.title }}
</v-tab>
<v-btn icon class="mt-2 ml-4" @click="editViewsDialog = true">
<v-icon>mdi-pencil</v-icon>
</v-btn>
</v-tabs>
<v-data-table
:headers="filteredHeaders"
:items="items"
hide-default-footer
class="mt-4"
:items-per-page="Number.MAX_VALUE"
hide-default-footer
class="mt-4 templates-table"
single-expand
show-expand
:headers="filteredHeaders"
:items="items"
:items-per-page="Number.MAX_VALUE"
:expanded.sync="openedItems"
:style="{
opacity: viewItemsLoading ? 0.3 : 1,
}"
>
<template v-slot:item.alias="{ item }">
<router-link :to="`/project/${projectId}/templates/${item.id}`">
{{ item.alias }}
<v-icon class="mr-3" small>
{{ TEMPLATE_TYPE_ICONS[item.type] }}
</v-icon>
<router-link
:to="`/project/${projectId}/templates/${item.id}`">{{ item.alias }}
</router-link>
</template>
<template v-slot:item.version="{ item }">
<TaskLink
v-if="item.last_task && item.last_task.tpl_type !== ''"
:disabled="true"
:status="item.last_task.status"
:task-id="item.last_task.tpl_type === 'build'
? item.last_task.id
: item.last_task.build_task.id"
:label="item.last_task.tpl_type === 'build'
? item.last_task.version
: item.last_task.build_task.version"
:tooltip="item.last_task.tpl_type === 'build'
? item.last_task.message
: item.last_task.build_task.message"
/>
<div v-else>&mdash;</div>
</template>
<template v-slot:item.status="{ item }">
<div class="mt-2 mb-2 d-flex" v-if="item.last_task != null">
<TaskStatus :status="item.last_task.status"/>
</div>
<div v-else class="mt-3 mb-2 d-flex" style="color: gray;">Not launched</div>
</template>
<template v-slot:item.last_task="{ item }">
<div class="mt-2 mb-2" v-if="item.last_task != null" style="line-height: 1">
<TaskLink
:task-id="item.last_task.id"
:label="'#' + item.last_task.id"
:tooltip="item.last_task.message"
/>
<div style="color: gray; font-size: 14px;">
by {{ item.last_task.user_name }} {{ item.last_task.created|formatDate }}
</div>
</div>
</template>
<template v-slot:item.inventory_id="{ item }">
{{ inventory.find((x) => x.id === item.inventory_id).name }}
</template>
@ -90,48 +180,103 @@
<template v-slot:item.actions="{ item }">
<v-btn text color="black" class="pl-1 pr-2" @click="createTask(item.id)">
<v-icon class="pr-1">mdi-play</v-icon>
Run
<v-icon class="pr-1">mdi-replay</v-icon>
{{ TEMPLATE_TYPE_ACTION_TITLES[item.type] }}
</v-btn>
</template>
<template v-slot:expanded-item="{ headers, item }">
<td
:colspan="headers.length"
v-if="openedItems.some((template) => template.id === item.id)"
>
<TaskList
style="border: 1px solid lightgray; border-radius: 6px; margin: 10px 0;"
:template="item"
:limit="5"
:hide-footer="true"
/>
</td>
</template>
</v-data-table>
<TableSettingsSheet
v-model="settingsSheet"
table-name="project__template"
:headers="headers"
@change="onTableSettingsChange"
v-model="settingsSheet"
table-name="project__template"
:headers="headers"
@change="onTableSettingsChange"
/>
</div>
</template>
<style lang="scss">
@import '~vuetify/src/styles/settings/_variables';
.templates-table .text-start:first-child {
padding-right: 0 !important;
}
@media #{map-get($display-breakpoints, 'sm-and-down')} {
.templates-table .v-data-table__mobile-row:first-child {
display: none !important;
}
}
</style>
<script>
import ItemListPageBase from '@/components/ItemListPageBase';
import TemplateForm from '@/components/TemplateForm.vue';
import TaskLink from '@/components/TaskLink.vue';
import axios from 'axios';
import TaskForm from '@/components/TaskForm.vue';
import EditViewsForm from '@/components/EditViewsForm.vue';
import TableSettingsSheet from '@/components/TableSettingsSheet.vue';
import TaskList from '@/components/TaskList.vue';
import EventBus from '@/event-bus';
import TaskStatus from '@/components/TaskStatus.vue';
import socket from '@/socket';
import { TEMPLATE_TYPE_ACTION_TITLES, TEMPLATE_TYPE_ICONS } from '../../lib/constants';
export default {
components: { TemplateForm, TaskForm, TableSettingsSheet },
components: {
TemplateForm, TaskForm, TableSettingsSheet, TaskStatus, TaskLink, TaskList, EditViewsForm,
},
mixins: [ItemListPageBase],
async created() {
socket.addListener((data) => this.onWebsocketDataReceived(data));
await this.loadData();
},
data() {
return {
TEMPLATE_TYPE_ICONS,
TEMPLATE_TYPE_ACTION_TITLES,
inventory: null,
environment: null,
repositories: null,
newTaskDialog: null,
taskId: null,
settingsSheet: null,
filteredHeaders: [],
openedItems: [],
views: null,
editViewsDialog: null,
viewItemsLoading: null,
viewTab: null,
};
},
computed: {
viewId() {
if (/^-?\d+$/.test(this.$route.params.viewId)) {
return parseInt(this.$route.params.viewId, 10);
}
return this.$route.params.viewId;
},
templateType() {
if (this.itemId == null || this.itemId === 'new') {
return '';
}
return this.items.find((x) => x.id === this.itemId).type;
},
templateAlias() {
if (this.itemId == null || this.itemId === 'new') {
return '';
@ -141,19 +286,95 @@ export default {
isLoaded() {
return this.items
&& this.inventory
&& this.environment
&& this.repositories;
&& this.inventory
&& this.environment
&& this.repositories
&& this.views;
},
},
watch: {
async viewId() {
this.viewItemsLoading = true;
try {
await this.loadItems();
if (this.viewId) {
localStorage.setItem(`project${this.projectId}__lastVisitedViewId`, this.viewId);
} else {
localStorage.removeItem(`project${this.projectId}__lastVisitedViewId`);
}
} finally {
this.viewItemsLoading = false;
}
},
},
methods: {
async beforeLoadItems() {
await this.loadViews();
},
getViewUrl(viewId) {
if (viewId == null) {
return `/project/${this.projectId}/templates`;
}
return `/project/${this.projectId}/views/${viewId}/templates`;
},
async loadViews() {
this.views = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/views`,
responseType: 'json',
})).data;
this.views.sort((v1, v2) => v1.position - v2.position);
if (this.viewId != null && !this.views.some((v) => v.id === this.viewId)) {
await this.$router.push({ path: `/project/${this.projectId}/templates` });
}
},
async closeEditViewDialog() {
this.editViewsDialog = false;
await this.loadViews();
},
async onWebsocketDataReceived(data) {
if (data.project_id !== this.projectId || data.type !== 'update') {
return;
}
const template = this.items.find((item) => item.id === data.template_id);
if (template == null) {
return;
}
if (data.task_id !== template.last_task_id) {
Object.assign(template.last_task, (await axios({
method: 'get',
url: `/api/project/${this.projectId}/tasks/${data.task_id}`,
responseType: 'json',
})).data);
template.last_task_id = data.task_id;
}
Object.assign(template.last_task, {
...data,
type: undefined,
});
},
onTaskCreated(e) {
EventBus.$emit('i-show-task', {
taskId: e.item.id,
});
},
showTaskLog(taskId) {
EventBus.$emit('i-show-task', {
taskId,
});
},
createTask(itemId) {
this.itemId = itemId;
this.newTaskDialog = true;
@ -165,6 +386,21 @@ export default {
text: 'Alias',
value: 'alias',
},
{
text: 'Version',
value: 'version',
sortable: false,
},
{
text: 'Status',
value: 'status',
sortable: false,
},
{
text: 'Last task',
value: 'last_task',
sortable: false,
},
{
text: 'Playbook',
value: 'playbook',
@ -195,7 +431,9 @@ export default {
},
getItemsUrl() {
return `/api/project/${this.projectId}/templates`;
return this.viewId == null
? `/api/project/${this.projectId}/templates`
: `/api/project/${this.projectId}/views/${this.viewId}/templates`;
},
async loadData() {