Puberty commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// +build !binassets
|
||||
|
||||
package assets
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func Handler() http.Handler {
|
||||
return http.NotFoundHandler()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user