Puberty commit

This commit is contained in:
2017-10-29 23:06:41 +01:00
commit 34034b5223
63 changed files with 16011 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
vendor
build
api/assets/bindata_assetfs.go
config.toml
+69
View File
@@ -0,0 +1,69 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/BurntSushi/toml"
packages = ["."]
revision = "b26d9c308763d68093482582cea63d69be07a0f0"
version = "v0.3.0"
[[projects]]
name = "github.com/Sirupsen/logrus"
packages = ["."]
revision = "f006c2ac4710855cf0f916dd6b77acf6b048dc6e"
version = "v1.0.3"
[[projects]]
branch = "master"
name = "github.com/elazarl/go-bindata-assetfs"
packages = ["."]
revision = "30f82fa23fd844bd5bb1e5f216db87fd77b5eb43"
[[projects]]
name = "github.com/go-sql-driver/mysql"
packages = ["."]
revision = "a0583e0143b1624142adab07e0e97fe106d99561"
version = "v1.3"
[[projects]]
branch = "master"
name = "github.com/jmoiron/sqlx"
packages = [".","reflectx"]
revision = "d9bd385d68c068f1fabb5057e3dedcbcbb039d0f"
[[projects]]
branch = "master"
name = "github.com/juju/errors"
packages = ["."]
revision = "c7d06af17c68cd34c835053720b21f6549d9b0ee"
[[projects]]
name = "github.com/julienschmidt/httprouter"
packages = ["."]
revision = "8c199fb6259ffc1af525cc3ad52ee60ba8359669"
version = "v1.1"
[[projects]]
name = "github.com/satori/go.uuid"
packages = ["."]
revision = "879c5887cd475cd7864858769793b2ceb0d44feb"
version = "v1.1.0"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = ["ssh/terminal"]
revision = "2509b142fb2b797aa7587dad548f113b2c0f20ce"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = ["unix","windows"]
revision = "b98136db334ff9cb24f28a68e3be3cb6608f7630"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "6c201fcf68612a8ad33e6e620125ca3a88e8e4c34d7acc2b5ed0f452c3457a3f"
solver-name = "gps-cdcl"
solver-version = 1
+53
View File
@@ -0,0 +1,53 @@
[[constraint]]
name = "github.com/jmoiron/sqlx"
branch = "master"
[[constraint]]
name = "github.com/go-sql-driver/mysql"
version = "1.3.0"
[[constraint]]
name = "github.com/julienschmidt/httprouter"
version = "1.1.0"
[[constraint]]
name = "github.com/elazarl/go-bindata-assetfs"
branch = "master"
[[constraint]]
name = "github.com/BurntSushi/toml"
version = "v0.3.0"
[[constraint]]
name = "github.com/juju/errors"
branch = "master"
[[constraint]]
name = "github.com/satori/go.uuid"
version = "1.1.0"
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
[[constraint]]
name = "github.com/Sirupsen/logrus"
version = "1.0.3"
+154
View File
@@ -0,0 +1,154 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/julienschmidt/httprouter"
"github.com/localhots/cmdui/backend/api/assets"
"github.com/localhots/cmdui/backend/api/auth"
"github.com/localhots/cmdui/backend/config"
"github.com/localhots/cmdui/backend/db"
"github.com/localhots/cmdui/backend/log"
)
// Start starts a web server that runs both backend API and serves assets that
// support the UI.
func Start() error {
assHand := assets.Handler()
router.NotFound = func(w http.ResponseWriter, r *http.Request) {
assHand.ServeHTTP(w, r)
}
cfg := config.Get().Server
log.Logger().Infof("Starting command UI server at %s:%d", cfg.Host, cfg.Port)
return http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), router)
}
//
// Endpoints
//
type handle func(ctx context.Context, w http.ResponseWriter, r *http.Request)
const rootPath = "/"
var router = httprouter.New()
func openEndpoint(h handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
ctx := contextWithParams(r.Context(), params)
h(ctx, w, r)
}
}
func protectedEndpoint(h handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
ctx, err := auth.AuthenticateRequest(w, r)
if err != nil {
renderUnauthorized(w, err)
return
}
ctx = contextWithParams(ctx, params)
h(ctx, w, r)
}
}
//
// Rendering
//
// renderJSON is a convinience function that encodes any value as JSON and
// writes it to response with appropriate headers included.
func renderJSON(w http.ResponseWriter, v interface{}) {
body, err := json.Marshal(v)
if err != nil {
renderError(w, err, http.StatusInternalServerError, "Failed to encode response into JSON")
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(body)
}
func renderError(w http.ResponseWriter, err error, status int, msg string) {
log.WithFields(log.F{
"status": status,
"error": err,
}).Warnf("Request failed: %s", msg)
http.Error(w, msg, status)
}
func renderUnauthorized(w http.ResponseWriter, err error) {
renderError(w, err, http.StatusUnauthorized, "Unauthorized")
}
//
// Params and context
//
type ctxKey string
const (
ctxParamsKey ctxKey = "params"
)
func contextWithParams(ctx context.Context, params httprouter.Params) context.Context {
return context.WithValue(ctx, ctxParamsKey, params)
}
func paramsFromContext(ctx context.Context) (params httprouter.Params, ok bool) {
v := ctx.Value(ctxParamsKey)
if v == nil {
return nil, false
}
return v.(httprouter.Params), true
}
func param(ctx context.Context, name string) string {
if params, ok := paramsFromContext(ctx); ok {
return params.ByName(name)
}
return ""
}
func requestedPage(r *http.Request) db.Page {
offset := r.FormValue("offset")
limit := r.FormValue("limit")
if offset == "" && limit == "" {
return db.Page{}
}
str2uint := func(s string) uint {
u, _ := strconv.ParseUint(s, 10, 64)
return uint(u)
}
return db.Page{
Offset: str2uint(offset),
Limit: str2uint(limit),
}
}
//
// Utils
//
// unbufferedWriter is an implementation of http.ResponseWriter that flushes the
// buffer after every write.
type unbufferedWriter struct {
http.ResponseWriter
}
func (w unbufferedWriter) Write(p []byte) (int, error) {
n, err := w.ResponseWriter.Write(p)
if f, ok := w.ResponseWriter.(http.Flusher); ok && err == nil {
f.Flush()
}
return n, err
}
+33
View File
@@ -0,0 +1,33 @@
// +build binassets
package assets
import (
"net/http"
"strings"
assetfs "github.com/elazarl/go-bindata-assetfs"
)
func Handler() http.Handler {
// Following functions would be defined in bindata_assetfs.go during the
// build process
return http.FileServer(&assetfs.AssetFS{
Asset: reactRouter,
AssetDir: AssetDir,
AssetInfo: AssetInfo,
Prefix: "/",
})
}
func reactRouter(path string) ([]byte, error) {
reactPrefixes := []string{"cmd", "jobs", "users"}
for _, prefix := range reactPrefixes {
if strings.HasPrefix(path, prefix) {
return Asset("index.html")
}
}
return Asset(path)
}
+11
View File
@@ -0,0 +1,11 @@
// +build !binassets
package assets
import (
"net/http"
)
func Handler() http.Handler {
return http.NotFoundHandler()
}
+50
View File
@@ -0,0 +1,50 @@
package auth
import (
"context"
"github.com/localhots/cmdui/backend/db"
)
type ctxKey string
const (
ctxSessionKey ctxKey = "session"
ctxUserKey ctxKey = "user"
)
func ContextWithSession(ctx context.Context, sess db.Session) context.Context {
return context.WithValue(ctx, ctxSessionKey, sess)
}
func SessionFromContext(ctx context.Context) (sess db.Session, ok bool) {
v := ctx.Value(ctxSessionKey)
if v == nil {
return db.Session{}, false
}
return v.(db.Session), true
}
func Session(ctx context.Context) db.Session {
sess, _ := SessionFromContext(ctx)
return sess
}
func ContextWithUser(ctx context.Context, u db.User) context.Context {
return context.WithValue(ctx, ctxUserKey, u)
}
func UserFromContext(ctx context.Context) (u db.User, ok bool) {
v := ctx.Value(ctxUserKey)
if v == nil {
return db.User{}, false
}
return v.(db.User), true
}
func User(ctx context.Context) db.User {
u, _ := UserFromContext(ctx)
return u
}
+60
View File
@@ -0,0 +1,60 @@
package auth
import (
"context"
"net/http"
"time"
"github.com/juju/errors"
)
const (
sessionCookieName = "cmdui_session_id"
)
func AuthenticateRequest(w http.ResponseWriter, r *http.Request) (context.Context, error) {
cook, err := r.Cookie(sessionCookieName)
if err != nil {
return r.Context(), errors.Annotate(err, "Failed to get cookie value")
}
sess, err := FindSession(cook.Value)
if err != nil {
return r.Context(), errors.Annotate(err, "Failed to authenticate request")
}
ctx := ContextWithSession(r.Context(), sess)
if sess.ExpiresAt.Before(time.Now()) {
ClearCookie(ctx, w)
return ctx, errors.New("Session expired")
}
u, err := sess.User()
if err != nil {
return ctx, errors.Annotatef(err, "Failed to find user %d", sess.UserID)
}
if u == nil {
return ctx, errors.UserNotFoundf("User %s was not found", sess.UserID)
}
u.Authorized = true
ctx = ContextWithUser(ctx, *u)
return ctx, nil
}
func AuthorizeResponse(ctx context.Context, w http.ResponseWriter) {
if sess, ok := SessionFromContext(ctx); ok {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sess.ID,
Path: "/",
Expires: sess.ExpiresAt,
HttpOnly: true,
})
}
}
func ClearCookie(ctx context.Context, w http.ResponseWriter) {
sess := Session(ctx)
sess.ExpiresAt = time.Time{}
ctx = ContextWithSession(ctx, sess)
AuthorizeResponse(ctx, w)
}
+52
View File
@@ -0,0 +1,52 @@
package auth
import (
"sync"
"github.com/juju/errors"
"github.com/localhots/cmdui/backend/db"
)
var (
sessionCacheMux sync.Mutex
sessionCache = map[string]db.Session{}
errSessionNotFound = errors.New("Session not found")
)
func FindSession(id string) (db.Session, error) {
if id == "" {
return db.Session{}, errSessionNotFound
}
sessionCacheMux.Lock()
sessc, ok := sessionCache[id]
sessionCacheMux.Unlock()
if ok {
return sessc, nil
}
sess, err := db.FindSession(id)
if err != nil {
return db.Session{}, errors.Annotate(err, "Session lookup failed")
}
if sess == nil {
return db.Session{}, errSessionNotFound
}
sessionCacheMux.Lock()
sessionCache[sess.ID] = *sess
sessionCacheMux.Unlock()
return *sess, nil
}
func CacheSession(sess db.Session) {
if sess.ID == "" {
return
}
sessionCacheMux.Lock()
sessionCache[sess.ID] = sess
sessionCacheMux.Unlock()
}
+115
View File
@@ -0,0 +1,115 @@
package api
import (
"context"
"net/http"
"github.com/juju/errors"
"github.com/localhots/cmdui/backend/api/auth"
"github.com/localhots/cmdui/backend/api/github"
"github.com/localhots/cmdui/backend/db"
)
const (
callbackURL = "/api/auth/callback"
)
func init() {
router.GET("/api/auth/login", openEndpoint(authLoginHandler))
router.POST("/api/auth/logout", protectedEndpoint(authLogoutHandler))
router.GET("/api/auth/session", protectedEndpoint(authSessionHandler))
router.GET(callbackURL, openEndpoint(authCallbackHandler))
}
// authSessionHandler returns currently authenticated user details.
// GET /auth/session
func authSessionHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
renderJSON(w, auth.User(ctx))
}
// authLogoutHandler clears authentication cookies.
// GET /auth/session
func authLogoutHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
auth.ClearCookie(ctx, w)
http.Redirect(w, r, rootPath, http.StatusTemporaryRedirect)
}
// authLoginHandler redirects user to a GitHub login page.
func authLoginHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
github.RedirectToLogin(w, r)
}
// authCallbackHandler accepts GitHub auth callback.
func authCallbackHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
ghu, err := authWithGithubCode(ctx, r.FormValue("code"))
if err != nil {
renderError(w, err, http.StatusInternalServerError, "GitHub login failed")
return
}
u, err := findOrCreateUser(ghu)
if err != nil {
renderError(w, err, http.StatusInternalServerError, "Failed to find a user using GitHub profile")
return
}
sess := db.NewSession(u.ID)
if err := sess.Create(); err != nil {
renderError(w, err, http.StatusInternalServerError, "Failed to create a session")
return
}
ctx = auth.ContextWithSession(ctx, sess)
auth.AuthorizeResponse(ctx, w)
auth.CacheSession(sess)
http.Redirect(w, r, rootPath, http.StatusTemporaryRedirect)
}
func authWithGithubCode(ctx context.Context, code string) (github.User, error) {
if code == "" {
return github.User{}, errors.New("Missing authentication code")
}
accessToken, err := github.ExchangeCode(ctx, code)
if err != nil {
return github.User{}, errors.Annotate(err, "Failed to exchange code for access token")
}
ghu, err := github.AuthDetails(accessToken)
if err != nil {
return ghu, errors.Annotate(err, "Failed to fetch authenticated GitHub user details")
}
return ghu, nil
}
func findOrCreateUser(ghu github.User) (db.User, error) {
u, err := db.FindUserByGithubID(ghu.ID)
if err != nil {
return db.User{}, errors.Annotate(err, "Failed to find GitHub user")
}
if u != nil {
importGithubProfile(u, ghu)
if err := u.Update(); err != nil {
return *u, errors.Annotate(err, "Failed to update a user")
}
} else {
eu := db.NewUser()
u = &eu
importGithubProfile(u, ghu)
if err := u.Create(); err != nil {
return *u, errors.Annotate(err, "Failed to create a user")
}
}
return *u, nil
}
func importGithubProfile(u *db.User, ghu github.User) {
u.GithubID = ghu.ID
u.GithubLogin = ghu.Login
u.Name = ghu.Name
u.Picture = ghu.Picture
}
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/localhots/cmdui/backend/api/auth"
"github.com/localhots/cmdui/backend/commands"
"github.com/localhots/cmdui/backend/runner"
)
func init() {
router.GET("/api/commands", protectedEndpoint(commandsHandler))
router.POST("/api/exec", protectedEndpoint(execHandler))
}
// commandsHandler returns a list of commands that are registered on the server.
// GET /commands
func commandsHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
renderJSON(w, commands.List())
}
// execHandler accepts a form from the UI, decodes it into a command and
// attempts to execute it. Raw job ID is returned in the response.
// POST /exec
func execHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
cmd, err := commandFromRequest(r)
if err != nil {
renderError(w, err, http.StatusBadRequest, "Failed to build a command from request form")
return
}
proc, err := runner.Start(cmd, auth.User(ctx))
if err != nil {
renderError(w, err, http.StatusInternalServerError, "Failed to execute a command")
return
}
w.WriteHeader(http.StatusCreated)
renderJSON(w, proc.Job)
}
// commandFromRequest parses a form submitted from UI and converts it into a
// command.
func commandFromRequest(r *http.Request) (commands.Command, error) {
var cmd commands.Command
err := r.ParseForm()
if err != nil {
return cmd, err
}
for _, c := range commands.List() {
if c.Name == r.PostForm.Get("command") {
cmd = c
break
}
}
if cmd.Name == "" {
return cmd, fmt.Errorf("Unknown command: %q", r.PostForm.Get("command"))
}
for key, val := range r.PostForm {
if key == "args" {
cmd.Args = val[0]
} else if strings.HasPrefix(key, "flags[") {
name := key[6 : len(key)-1]
for i, f := range cmd.Flags {
if f.Name == name {
cmd.Flags[i].Value = val[0]
}
}
}
}
return cmd, nil
}
+91
View File
@@ -0,0 +1,91 @@
package github
import (
"bytes"
"context"
"encoding/json"
"io/ioutil"
"net/http"
"net/url"
"github.com/juju/errors"
"github.com/localhots/cmdui/backend/config"
)
type User struct {
ID uint `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Picture string `json:"avatar_url"`
}
const (
authorizeURL = "https://github.com/login/oauth/authorize"
accessTokenURL = "https://github.com/login/oauth/access_token"
userDetailsURL = "https://api.github.com/user"
)
func RedirectToLogin(w http.ResponseWriter, r *http.Request) {
urlStr := authorizeURL + "?" + url.Values{
"client_id": {config.Get().Github.ClientID},
"scope": {"user"},
}.Encode()
http.Redirect(w, r, urlStr, http.StatusTemporaryRedirect)
}
func ExchangeCode(ctx context.Context, code string) (accessToken string, err error) {
cfg := config.Get()
reqBody := bytes.NewBufferString(url.Values{
"client_id": {cfg.Github.ClientID},
"client_secret": {cfg.Github.ClientSecret},
"code": {code},
}.Encode())
req, err := http.NewRequest(http.MethodPost, accessTokenURL, reqBody)
if err != nil {
return "", errors.Annotate(err, "Failed to create a code exchange request")
}
// Passing client request context
req = req.WithContext(ctx)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", errors.Annotate(err, "Failed to perform code exchange request")
}
defer resp.Body.Close()
respBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", errors.Annotate(err, "Failed to read access token response")
}
uri, err := url.ParseQuery(string(respBody))
if err != nil {
return "", errors.Annotate(err, "Failed to parse access token response")
}
return uri.Get("access_token"), nil
}
func AuthDetails(accessToken string) (User, error) {
var u User
reqURL := userDetailsURL + "?" + url.Values{
"access_token": {accessToken},
}.Encode()
resp, err := http.Get(reqURL)
if err != nil {
return u, errors.Annotate(err, "Failed to fetch authenticated user details")
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return u, errors.Annotate(err, "Failed to read authenticated user details")
}
if err := json.Unmarshal(body, &u); err != nil {
return u, errors.Annotate(err, "Failed to parse authenticated user details")
}
return u, nil
}
+177
View File
@@ -0,0 +1,177 @@
package api
import (
"context"
"fmt"
"net/http"
"syscall"
"github.com/juju/errors"
"github.com/localhots/cmdui/backend/commands"
"github.com/localhots/cmdui/backend/db"
"github.com/localhots/cmdui/backend/runner"
)
func init() {
router.GET("/api/jobs", protectedEndpoint(jobsIndexHandler))
router.GET("/api/jobs/:job_id", protectedEndpoint(jobShowHandler))
router.PUT("/api/jobs/:job_id", protectedEndpoint(jobActionHandler))
router.GET("/api/jobs/:job_id/log", protectedEndpoint(jobLogHandler))
router.GET("/api/commands/:cmd/jobs", protectedEndpoint(jobsIndexHandler))
router.GET("/api/users/:user_id/jobs", protectedEndpoint(jobsIndexHandler))
}
// jobLogHandler returns job's log. If the command is still running, than the
// client would be attached to a log file and receive updates as well as few
// previous lines of it. If the command is completed, the entire log would be
// returned. If a `full` parameter is provided and the command is still running,
// the client would receive all existing log contents and future updates.
// GET /api/jobs/:job_id/log
// FIXME: What the fuck is this function
func jobLogHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
id := param(ctx, "job_id")
proc := runner.FindProcess(id)
if proc != nil {
var done <-chan struct{}
var err error
if r.FormValue("full") != "" {
done, err = runner.ReadFullLog(ctx, proc, unbufferedWriter{w})
} else {
done, err = runner.ReadLogUpdates(ctx, proc, unbufferedWriter{w})
}
if err != nil {
renderError(w, err, http.StatusInternalServerError, "Failed to tail a log")
}
<-done
return
}
proc = &runner.Process{ID: id}
done, err := runner.ReadFullLog(ctx, proc, unbufferedWriter{w})
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
<-done
}
// jobsIndexHandler returns a list of jobs for a given criteria.
// GET /api/jobs
// GET /api/commands/:cmd/jobs
// GET /api/users/:user_id/jobs
func jobsIndexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
renderJobs := func(jobs []db.Job, err error) {
if err != nil {
renderError(w, err, http.StatusInternalServerError, "Failed to find jobs")
} else {
renderJSON(w, jobs)
}
}
switch {
case param(ctx, "user_id") != "":
id := param(ctx, "user_id")
u, err := db.FindUser(id)
if err != nil {
renderError(w, err, http.StatusInternalServerError, "Failed to find a user")
}
if u == nil {
err := fmt.Errorf("User not found: %s", id)
renderError(w, err, http.StatusNotFound, "User not found")
return
}
renderJobs(db.FindUserJobs(id, requestedPage(r)))
case param(ctx, "cmd") != "":
cmdName := param(ctx, "cmd")
if _, ok := commands.Map()[cmdName]; ok {
renderJobs(db.FindCommandJobs(cmdName, requestedPage(r)))
} else {
err := fmt.Errorf("Command not found: %s", cmdName)
renderError(w, err, http.StatusNotFound, "Command not found")
}
default:
renderJobs(db.FindAllJobs(requestedPage(r)))
}
}
// jobShowHandler returns job details.
// GET /api/jobs/:job_id
func jobShowHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
id := param(ctx, "job_id")
job, err := db.FindJob(id)
if err != nil {
renderError(w, err, http.StatusInternalServerError, "Failed to find job")
return
}
if job != nil {
renderJSON(w, job)
} else {
w.WriteHeader(http.StatusNotFound)
}
}
// jobActionHandler performs certain actions on a job.
// PUT /api/jobs/:job_id
func jobActionHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
renderError(w, err, http.StatusBadRequest, "Failed to parse form")
return
}
id := r.PostForm.Get("id")
if id == "" {
err := errors.New("Job ID is required")
renderError(w, err, http.StatusBadRequest, err.Error())
return
}
proc := runner.FindProcess(id)
if proc == nil {
err := fmt.Errorf("Job %q was not found", id)
renderError(w, err, http.StatusNotFound, err.Error())
return
}
sigName := r.PostForm.Get("signal")
if sigName != "" {
sig, err := signalFromName(sigName)
if err != nil {
renderError(w, err, http.StatusBadRequest, err.Error())
return
}
err = proc.Signal(sig)
if err != nil {
renderError(w, err, http.StatusInternalServerError, "Failed to send signal to a process")
return
}
}
}
func signalFromName(name string) (syscall.Signal, error) {
switch name {
case "SIGHUP":
return syscall.SIGHUP, nil
case "SIGINT":
return syscall.SIGINT, nil
case "SIGKILL":
return syscall.SIGKILL, nil
case "SIGQUIT":
return syscall.SIGQUIT, nil
case "SIGTERM":
return syscall.SIGTERM, nil
case "SIGTTIN":
return syscall.SIGTTIN, nil
case "SIGTTOU":
return syscall.SIGTTOU, nil
case "SIGUSR1":
return syscall.SIGUSR1, nil
case "SIGUSR2":
return syscall.SIGUSR2, nil
default:
return 0, fmt.Errorf("Signal not supported: %s", name)
}
}
+30
View File
@@ -0,0 +1,30 @@
package api
import (
"context"
"fmt"
"net/http"
"github.com/localhots/cmdui/backend/db"
)
func init() {
router.GET("/api/users/:user_id", protectedEndpoint(userDetailsHandler))
}
// userDetailsHandler returns user details.
// GET /api/users/:user_id
func userDetailsHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
id := param(ctx, "user_id")
u, err := db.FindUser(id)
if err != nil {
renderError(w, err, http.StatusInternalServerError, "Failed to find a user")
}
if u == nil {
err := fmt.Errorf("User not found: %s", id)
renderError(w, err, http.StatusNotFound, "User not found")
return
}
renderJSON(w, u)
}
+78
View File
@@ -0,0 +1,78 @@
package commands
import (
"fmt"
"strings"
)
type Command struct {
Name string `json:"name"`
Description string `json:"description"`
Args string `json:"-"`
ArgsPlaceholder string `json:"args_placeholder"`
Flags []Flag `json:"flags"`
}
type Flag struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Default string `json:"default"`
Value string `json:"-"`
}
var (
list []Command
)
func Import(l []Command) {
list = l
}
// List returns a list of commands.
func List() []Command {
return list
}
// Map returns commands as a map.
func Map() map[string]Command {
m := make(map[string]Command, len(list))
for _, cmd := range list {
m[cmd.Name] = cmd
}
return m
}
func (c Command) CombinedArgs() []string {
args := []string{c.Name}
args = append(args, c.ArgsSlice()...)
args = append(args, c.FlagsSlice()...)
return args
}
func (c Command) ArgsSlice() []string {
if c.Args == "" {
return []string{}
}
return strings.Split(c.Args, " ")
}
func (c Command) FlagsSlice() []string {
flags := []string{}
for _, f := range c.Flags {
flags = append(flags, f.encode())
}
return flags
}
func (c Command) FlagsString() string {
return strings.Join(c.FlagsSlice(), " ")
}
func (c Command) String() string {
return strings.Join(c.CombinedArgs(), " ")
}
func (f Flag) encode() string {
return fmt.Sprintf("--%s=%s", f.Name, f.Value)
}
+17
View File
@@ -0,0 +1,17 @@
log_dir = "/var/log/commands"
[commands]
base_path = "/usr/local/bin/runner"
config_command = "/usr/local/bin/runner_commands --export=json"
[database]
driver = "mysql"
spec = "root:@(localhost:3306)/commands?charset=utf8mb4&parseTime=true"
[server]
host = "http://127.0.0.1"
port = 9090
[github]
client_id = "secret"
client_secret = "secret"
+71
View File
@@ -0,0 +1,71 @@
package config
import (
"github.com/BurntSushi/toml"
"github.com/juju/errors"
)
// Config is used to provide configuration to the server.
type Config struct {
LogDir string `toml:"log_dir"`
Commands struct {
BasePath string `toml:"base_path"`
ConfigCommand string `toml:"config_command"`
} `toml:"commands"`
Database struct {
Driver string `toml:"driver"`
Spec string `toml:"spec"`
} `toml:"database"`
Server struct {
Host string `toml:"host"`
Port uint16 `toml:"port"`
} `toml:"server"`
Github struct {
ClientID string `toml:"client_id"`
ClientSecret string `toml:"client_secret"`
} `toml:"github"`
}
var cfg *Config
func Get() Config {
if cfg == nil {
panic("Config is not installed")
}
return *cfg
}
func Install(c Config) error {
switch {
case c.Commands.BasePath == "":
return errors.New("Base command path is not configured")
case c.Commands.ConfigCommand == "":
return errors.New("Config command is not configured")
case c.Database.Driver == "":
return errors.New("Database driver is not configured")
case c.Database.Spec == "":
return errors.New("Database spec is not configured")
case c.Server.Host == "":
return errors.New("Server host is not configured")
case c.Server.Port == 0:
return errors.New("Server port is not configured")
case c.Github.ClientID == "":
return errors.New("GitHub client ID is not configured")
case c.Github.ClientSecret == "":
return errors.New("GitHub client secret is not configured")
case c.LogDir == "":
return errors.New("Log directory is not configured")
default:
cfg = &c
return nil
}
}
func LoadFile(file string) (Config, error) {
var c Config
_, err := toml.DecodeFile(file, &c)
if err != nil {
return c, errors.Annotate(err, "Failed to parse config file")
}
return c, Install(c)
}
+80
View File
@@ -0,0 +1,80 @@
package db
import (
"reflect"
"strings"
_ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
uuid "github.com/satori/go.uuid"
"github.com/localhots/cmdui/backend/config"
)
var (
db *sqlx.DB
)
func Connect() error {
var err error
cfg := config.Get().Database
db, err = sqlx.Connect(cfg.Driver, cfg.Spec)
return err
}
type Page struct {
Offset uint
Limit uint
}
func (p Page) normalize() Page {
const defaultPerPage = 50
if p.Limit == 0 {
p.Limit = defaultPerPage
}
return p
}
//
// Helpers
//
func newID() string {
return uuid.NewV4().String()
}
func placeholders(val interface{}) string {
v := reflect.ValueOf(val)
if v.Kind() == reflect.Slice {
s := strings.Repeat("?, ", v.Len())
return s[0 : len(s)-2]
}
return "?"
}
func iargs(args []string) []interface{} {
iargs := make([]interface{}, len(args))
for i, arg := range args {
iargs[i] = arg
}
return iargs
}
type stringSet map[string]struct{}
func (s stringSet) add(items ...string) {
for _, item := range items {
s[item] = struct{}{}
}
}
func (s stringSet) items() []string {
l := make([]string, len(s))
i := 0
for item := range s {
l[i] = item
i++
}
return l
}
+168
View File
@@ -0,0 +1,168 @@
package db
import (
"database/sql"
"fmt"
"time"
"github.com/juju/errors"
"github.com/localhots/cmdui/backend/commands"
)
type JobState string
const (
JobStateNew JobState = "new"
JobStateCreated JobState = "created"
JobStateStarted JobState = "started"
JobStateAborted JobState = "aborted"
JobStateFailed JobState = "failed"
JobStateFinished JobState = "finished"
)
type Job struct {
ID string `json:"id" db:"id"`
Command string `json:"command" db:"command"`
Args string `json:"args" db:"args"`
Flags string `json:"flags" db:"flags"`
UserID string `json:"user_id" db:"user_id"`
User *User `json:"user" db:"-"`
State string `json:"state" db:"state"`
CreatedAt *time.Time `json:"created_at" db:"created_at"`
StartedAt *time.Time `json:"started_at" db:"started_at"`
FinishedAt *time.Time `json:"finished_at" db:"finished_at"`
}
func NewJob(c commands.Command, u User) Job {
return Job{
ID: newID(),
Command: c.Name,
Args: c.Args,
Flags: c.FlagsString(),
UserID: u.ID,
User: &u,
State: string(JobStateNew),
}
}
func FindAllJobs(p Page) ([]Job, error) {
return findJobsWhere(jobsIndexQuery("", p))
}
func FindCommandJobs(name string, p Page) ([]Job, error) {
return findJobsWhere(jobsIndexQuery("WHERE command = ?", p), name)
}
func FindUserJobs(id string, p Page) ([]Job, error) {
return findJobsWhere(jobsIndexQuery("WHERE user_id = ?", p), id)
}
func jobsIndexQuery(where string, p Page) string {
p = p.normalize()
return fmt.Sprintf("SELECT * FROM jobs %s ORDER BY created_at DESC LIMIT %d, %d",
where, p.Offset, p.Limit)
}
func findJobsWhere(query string, args ...interface{}) ([]Job, error) {
var jobs []Job
err := db.Select(&jobs, query, args...)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Annotate(err, "Failed to load Jobs list")
}
userIDs := stringSet{}
for _, r := range jobs {
userIDs.add(r.UserID)
}
users, err := FindUsers(userIDs.items()...)
if err != nil {
return nil, errors.Annotate(err, "Failed to find users to embed into jobs")
}
for i, r := range jobs {
if u, ok := users[r.UserID]; ok {
jobs[i].User = &u
}
}
return jobs, nil
}
func FindJob(id string) (*Job, error) {
var r Job
err := db.Get(&r, "SELECT * FROM jobs WHERE id = ?", id)
if err != nil {
if err != sql.ErrNoRows {
return nil, errors.Annotate(err, "Failed to load Job details")
}
return nil, nil
}
if r.UserID != "" {
r.User, err = FindUser(r.UserID)
if err != nil {
return nil, errors.Annotate(err, "Failed to find a user to embed into a job")
}
}
return &r, nil
}
func (r *Job) UpdateState(s JobState) error {
r.State = string(s)
ts := time.Now().UTC()
switch s {
case JobStateStarted:
r.StartedAt = &ts
case JobStateFinished, JobStateAborted, JobStateFailed:
r.FinishedAt = &ts
}
return r.Update()
}
func (r *Job) Create() error {
ts := time.Now().UTC()
r.CreatedAt = &ts
r.State = string(JobStateCreated)
_, err := db.NamedExec(`
INSERT INTO jobs
SET
id = :id,
command = :command,
args = :args,
flags = :flags,
user_id = :user_id,
state = :state,
created_at = :created_at
`, r)
if err != nil {
return errors.Annotate(err, "Failed to create a job")
}
return nil
}
func (r Job) Update() error {
_, err := db.NamedExec(`
UPDATE jobs
SET
state = :state,
started_at = :started_at,
finished_at = :finished_at
WHERE
id = :id
`, r)
if err != nil {
return errors.Annotate(err, "Failed to update a job")
}
return nil
}
func jobsSliceToMap(s []Job) map[string]Job {
m := make(map[string]Job, len(s))
for _, r := range s {
m[r.ID] = r
}
return m
}
+60
View File
@@ -0,0 +1,60 @@
package db
import (
"database/sql"
"time"
"github.com/juju/errors"
)
type Session struct {
ID string `db:"id"`
UserID string `db:"user_id"`
CreatedAt time.Time `db:"created_at"`
ExpiresAt time.Time `db:"expires_at"`
}
func NewSession(userID string) Session {
const ttl = 6 * 30 * 24 * time.Hour // 6 months
now := time.Now().UTC()
exp := now.Add(ttl)
return Session{
ID: newID(),
UserID: userID,
CreatedAt: now,
ExpiresAt: exp,
}
}
func FindSession(id string) (*Session, error) {
var s Session
err := db.Get(&s, "SELECT * FROM sessions WHERE id = ?", id)
if err != nil {
if err != sql.ErrNoRows {
return nil, errors.Annotate(err, "Failed to load session details")
}
return nil, nil
}
return &s, nil
}
func (s Session) Create() error {
_, err := db.NamedExec(`
INSERT INTO sessions
SET
id = :id,
user_id = :user_id,
created_at = :created_at,
expires_at = :expires_at
`, s)
if err != nil {
return errors.Annotate(err, "Failed to create a session")
}
return nil
}
func (s Session) User() (*User, error) {
return FindUser(s.UserID)
}
+110
View File
@@ -0,0 +1,110 @@
package db
import (
"database/sql"
"fmt"
"github.com/juju/errors"
)
type User struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"github_name"`
Picture string `json:"picture" db:"github_picture"`
GithubID uint `json:"-" db:"github_id"`
GithubLogin string `json:"-" db:"github_login"`
Authorized bool `json:"authorized" db:"-"`
}
func NewUser() User {
return User{ID: newID()}
}
func FindAllUsers() (map[string]User, error) {
return findUsers("SELECT * FROM users ORDER BY id ASC")
}
func FindUsers(ids ...string) (map[string]User, error) {
if len(ids) == 0 {
return map[string]User{}, nil
}
query := fmt.Sprintf("SELECT * FROM users WHERE id IN (%s)", placeholders(ids))
return findUsers(query, iargs(ids)...)
}
func findUsers(query string, args ...interface{}) (map[string]User, error) {
var users []User
err := db.Select(&users, query, args...)
if err != nil && err != sql.ErrNoRows {
return nil, errors.Annotate(err, "Failed to load users list")
}
return usersSliceToMap(users), nil
}
func FindUser(id string) (*User, error) {
return findUser("SELECT * FROM users WHERE id = ?", id)
}
func FindUserByGithubID(id uint) (*User, error) {
return findUser("SELECT * FROM users WHERE github_id = ?", id)
}
func FindUserByLogin(login string) (*User, error) {
return findUser("SELECT * FROM users WHERE github_login = ?", login)
}
func findUser(query string, args ...interface{}) (*User, error) {
var u User
err := db.Get(&u, query, args...)
if err != nil {
if err != sql.ErrNoRows {
return nil, errors.Annotate(err, "Failed to load user details")
}
return nil, nil
}
return &u, nil
}
func (u User) Create() error {
_, err := db.NamedExec(`
INSERT INTO users
SET
id = :id,
github_id = :github_id,
github_login = :github_login,
github_name = :github_name,
github_picture = :github_picture
`, u)
if err != nil {
return errors.Annotate(err, "Failed to create a user")
}
return nil
}
func (u User) Update() error {
_, err := db.NamedExec(`
UPDATE users
SET
github_login = :github_login,
github_name = :github_name,
github_picture = :github_picture
WHERE
github_id = :github_id
`, u)
if err != nil {
return errors.Annotate(err, "Failed to update a user")
}
return nil
}
func usersSliceToMap(s []User) map[string]User {
m := make(map[string]User, len(s))
for _, u := range s {
m[u.ID] = u
}
return m
}
+18
View File
@@ -0,0 +1,18 @@
package log
import (
"github.com/Sirupsen/logrus"
)
// F is short for "fields".
type F map[string]interface{}
// WithFields is an entry function for logging.
func WithFields(f F) *logrus.Entry {
return logrus.WithFields(logrus.Fields(f))
}
// Logger returns a logger instance.
func Logger() *logrus.Logger {
return logrus.StandardLogger()
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"flag"
"github.com/localhots/cmdui/backend/api"
"github.com/localhots/cmdui/backend/commands"
"github.com/localhots/cmdui/backend/config"
"github.com/localhots/cmdui/backend/db"
"github.com/localhots/cmdui/backend/log"
"github.com/localhots/cmdui/backend/runner"
)
func main() {
confPath := flag.String("config", "config.toml", "Path to config file in TOML format")
flag.Parse()
_, err := config.LoadFile(*confPath)
if err != nil {
log.WithFields(log.F{"error": err}).Fatal("Failed to load config file")
}
if err := db.Connect(); err != nil {
log.WithFields(log.F{"error": err}).Fatal("Failed to establish database connection")
}
if err := runner.PrepareLogsDir(); err != nil {
log.WithFields(log.F{"error": err}).Fatal("Failed to create logs directory")
}
list, err := runner.CommandsList()
if err != nil {
log.WithFields(log.F{"error": err}).Fatal("Failed to import commands")
}
commands.Import(list)
defer runner.Shutdown()
if err := api.Start(); err != nil {
log.WithFields(log.F{"error": err}).Fatal("Failed to start the server")
}
}
+117
View File
@@ -0,0 +1,117 @@
package runner
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"time"
"github.com/juju/errors"
"github.com/localhots/cmdui/backend/commands"
"github.com/localhots/cmdui/backend/config"
"github.com/localhots/cmdui/backend/db"
"github.com/localhots/cmdui/backend/log"
)
func Start(cmd commands.Command, user db.User) (*Process, error) {
job := db.NewJob(cmd, user)
if err := job.Create(); err != nil {
return nil, errors.Annotate(err, "Failed to create a job")
}
p := &Process{
ID: job.ID,
Job: &job,
}
basePath := config.Get().Commands.BasePath
p.exec = exec.Command(basePath, cmd.CombinedArgs()...)
fd, err := p.useLogfile(p.logfile())
if err != nil {
return nil, err
}
p.exec.Stdout = fd
p.exec.Stderr = fd
if err := pool.add(context.Background(), p); err != nil {
return p, errors.Annotate(err, "Failed to start a process")
}
return p, nil
}
func ReadLogUpdates(ctx context.Context, p *Process, w io.Writer) (done <-chan struct{}, err error) {
cmde := exec.CommandContext(ctx, "/usr/bin/tail", "-n", "100", "-f", p.logfile())
return readLog(ctx, cmde, p, w)
}
func ReadFullLog(ctx context.Context, p *Process, w io.Writer) (done <-chan struct{}, err error) {
cmde := exec.CommandContext(ctx, "/bin/cat", p.logfile())
return readLog(ctx, cmde, p, w)
}
func readLog(ctx context.Context, cmde *exec.Cmd, p *Process, w io.Writer) (done <-chan struct{}, err error) {
cmde.Stdout = w
cmde.Stderr = w
tp := &Process{
ID: sysID("log-access"),
exec: cmde,
}
exited := make(chan struct{}, 2)
p.onExit(func(p *Process) {
exited <- struct{}{}
})
tp.onExit(func(p *Process) {
exited <- struct{}{}
})
if err := pool.add(ctx, tp); err != nil {
return nil, errors.Annotate(err, "Failed to start a tail process")
}
return exited, nil
}
func CommandsList() ([]commands.Command, error) {
var buf bytes.Buffer
cmde := exec.Command("/bin/bash", "-c", config.Get().Commands.ConfigCommand)
cmde.Stdout = &buf
cmde.Stderr = &buf
p := &Process{
ID: sysID("commands-list"),
exec: cmde,
}
exited := make(chan struct{})
p.onExit(func(p *Process) {
close(exited)
})
if err := pool.add(context.Background(), p); err != nil {
return nil, errors.Annotate(err, "Failed to import commands")
}
<-exited
body := buf.Bytes()
var list []commands.Command
if err := json.Unmarshal(body, &list); err != nil {
log.WithFields(log.F{
"error": err,
"json": string(body),
}).Error("Invalid commands JSON")
return nil, errors.Annotate(err, "Failed to decode commands JSON")
}
return list, nil
}
func sysID(name string) string {
return fmt.Sprintf("%s-%d", name, time.Now().UnixNano())
}
+155
View File
@@ -0,0 +1,155 @@
package runner
import (
"context"
"os"
"strings"
"sync"
"github.com/juju/errors"
"github.com/localhots/cmdui/backend/config"
"github.com/localhots/cmdui/backend/db"
"github.com/localhots/cmdui/backend/log"
)
var pool = &processPool{}
type processPool struct {
lock sync.RWMutex
wg sync.WaitGroup
procs map[string]*Process
}
func FindProcess(id string) *Process {
pool.lock.RLock()
defer pool.lock.RUnlock()
return pool.procs[id]
}
func Shutdown() {
pool.close()
}
func PrepareLogsDir() error {
return os.MkdirAll(config.Get().LogDir, 0755)
}
func (pp *processPool) add(ctx context.Context, p *Process) error {
// Validate process
if err := pp.validate(p); err != nil {
return errors.Annotate(err, "Process validation failed")
}
// Register process
pp.lock.Lock()
if pp.procs == nil {
pp.procs = make(map[string]*Process)
}
pp.procs[p.ID] = p
pp.lock.Unlock()
// Start process
if err := p.exec.Start(); err != nil {
log.WithFields(log.F{
"id": p.ID,
"error": err,
}).Error("Failed to start a command")
return errors.Annotate(err, "Failed to start a process")
}
p.PID = p.exec.Process.Pid
log.WithFields(log.F{
"id": p.ID,
"pid": p.PID,
"command": strings.Join(p.exec.Args, " "),
}).Info("Command started")
tryUpdateState(p, db.JobStateStarted)
pp.wg.Add(1)
go pp.handleProcessExit(p)
return nil
}
func (pp *processPool) handleProcessExit(p *Process) {
defer pp.wg.Done()
err := p.exec.Wait()
pp.lock.Lock()
delete(pp.procs, p.ID)
pp.lock.Unlock()
if err != nil {
log.WithFields(log.F{
"id": p.ID,
"pid": p.PID,
"error": err,
}).Error("Command failed")
tryUpdateState(p, db.JobStateFailed)
} else {
log.WithFields(log.F{
"id": p.ID,
"pid": p.PID,
}).Info("Command finished")
tryUpdateState(p, db.JobStateFinished)
}
if err := p.close(); err != nil {
log.WithFields(log.F{
"id": p.ID,
"error": err,
}).Error("Failed to close a job")
}
for _, onExit := range p.exitCallbacks {
onExit(p)
}
}
func (pp *processPool) procsList() []*Process {
pp.lock.RLock()
defer pp.lock.RUnlock()
list := make([]*Process, len(pp.procs))
i := 0
for _, p := range pp.procs {
list[i] = p
i++
}
return list
}
func (pp *processPool) validate(p *Process) error {
switch {
case p == nil:
return errors.New("Can't add an empty process")
case p.ID == "":
return errors.New("Can't add a process without an ID")
case p.exec == nil:
return errors.New("Process executable can't be empty")
default:
return nil
}
}
func (pp *processPool) close() {
pp.wg.Wait()
}
func tryUpdateState(p *Process, s db.JobState) {
log.WithFields(log.F{
"id": p.ID,
"state": s,
}).Debug("Job state changed")
if p.Job == nil {
return
}
if err := p.Job.UpdateState(s); err != nil {
log.WithFields(log.F{
"id": p.ID,
"state": s,
"error": err,
}).Error("Job state changed")
}
}
+60
View File
@@ -0,0 +1,60 @@
package runner
import (
"fmt"
"io"
"os"
"os/exec"
"syscall"
"github.com/juju/errors"
"github.com/localhots/cmdui/backend/commands"
"github.com/localhots/cmdui/backend/config"
"github.com/localhots/cmdui/backend/db"
)
type Process struct {
ID string `json:"id"`
PID int `json:"pid"`
Job *db.Job `json:"job"`
Command commands.Command `json:"command"`
Out io.Writer `json:"-"`
exec *exec.Cmd
log *os.File
exitCallbacks []func(p *Process) `json:"-"`
}
func (p *Process) Signal(s syscall.Signal) error {
return p.exec.Process.Signal(s)
}
func (p *Process) logfile() string {
return fmt.Sprintf("%s/%s.log", config.Get().LogDir, p.ID)
}
func (p *Process) useLogfile(path string) (io.Writer, error) {
fd, err := os.OpenFile(p.logfile(), os.O_CREATE|os.O_WRONLY, 0744)
if err != nil {
return nil, errors.Annotate(err, "Failed to create log file")
}
p.log = fd
return fd, nil
}
func (p *Process) onExit(fn func(p *Process)) {
p.exitCallbacks = append(p.exitCallbacks, fn)
}
func (p *Process) close() error {
if p.log != nil {
err := p.log.Close()
if err != nil {
return errors.Annotate(err, "Failed to close log file")
}
}
return nil
}