caddy/middleware/git/git.go
xenolf b4780a41d3 Added webhook functionality to the git middleware.
The webhook providers reside behind a small interface which determines if
a provider should run. If a provider should run it delegates
responsibility of the request to the provider.
ghdeploy initial commit

Added webhook functionality to the git middleware.
The webhook providers reside behind a small interface which determines if a provider should run. If a provider should run it delegates responsibility of the request to the provider.

Add tests

Remove old implementation

Fix inconsistency with git interval pulling.

Remove '\n' from logging statements and put the initial pull into a startup function
2015-06-04 03:24:16 +02:00

368 lines
9.1 KiB
Go

package git
import (
"bytes"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
"github.com/mholt/caddy/middleware"
"github.com/mholt/caddy/middleware/git/gitos"
)
// DefaultInterval is the minimum interval to delay before
// requesting another git pull
const DefaultInterval time.Duration = time.Hour * 1
// Number of retries if git pull fails
const numRetries = 3
// gitBinary holds the absolute path to git executable
var gitBinary string
// shell holds the shell to be used. Either sh or bash.
var shell string
// initMutex prevents parallel attempt to validate
// git requirements.
var initMutex = sync.Mutex{}
// Logger is used to log errors; if nil, the default log.Logger is used.
var Logger *log.Logger
// Services holds all git pulling services and provides the function to
// stop them.
var Services = &services{}
// logger is an helper function to retrieve the available logger
func logger() *log.Logger {
if Logger == nil {
Logger = log.New(os.Stderr, "", log.LstdFlags)
}
return Logger
}
// Repo is the structure that holds required information
// of a git repository.
type Repo struct {
URL string // Repository URL
Path string // Directory to pull to
Host string // Git domain host e.g. github.com
Branch string // Git branch
KeyPath string // Path to private ssh key
Interval time.Duration // Interval between pulls
Then string // Command to execute after successful git pull
pulled bool // true if there was a successful pull
lastPull time.Time // time of the last successful pull
lastCommit string // hash for the most recent commit
sync.Mutex
HookUrl string // url to listen on for webhooks
HookSecret string // secret to validate hooks
}
// Pull attempts a git clone.
// It retries at most numRetries times if error occurs
func (r *Repo) Pull() error {
r.Lock()
defer r.Unlock()
// prevent a pull if the last one was less than 5 seconds ago
if gos.TimeSince(r.lastPull) < 5*time.Second {
return nil
}
// keep last commit hash for comparison later
lastCommit := r.lastCommit
var err error
// Attempt to pull at most numRetries times
for i := 0; i < numRetries; i++ {
if err = r.pull(); err == nil {
break
}
logger().Println(err)
}
if err != nil {
return err
}
// check if there are new changes,
// then execute post pull command
if r.lastCommit == lastCommit {
logger().Println("No new changes.")
return nil
}
return r.postPullCommand()
}
// Pull performs git clone, or git pull if repository exists
func (r *Repo) pull() error {
params := []string{"clone", "-b", r.Branch, r.URL, r.Path}
if r.pulled {
params = []string{"pull", "origin", r.Branch}
}
// if key is specified, pull using ssh key
if r.KeyPath != "" {
return r.pullWithKey(params)
}
dir := ""
if r.pulled {
dir = r.Path
}
var err error
if err = runCmd(gitBinary, params, dir); err == nil {
r.pulled = true
r.lastPull = time.Now()
logger().Printf("%v pulled.\n", r.URL)
r.lastCommit, err = r.getMostRecentCommit()
}
return err
}
// pullWithKey is used for private repositories and requires an ssh key.
// Note: currently only limited to Linux and OSX.
func (r *Repo) pullWithKey(params []string) error {
var gitSSH, script gitos.File
// ensure temporary files deleted after usage
defer func() {
if gitSSH != nil {
gos.Remove(gitSSH.Name())
}
if script != nil {
gos.Remove(script.Name())
}
}()
var err error
// write git.sh script to temp file
gitSSH, err = writeScriptFile(gitWrapperScript())
if err != nil {
return err
}
// write git clone bash script to file
script, err = writeScriptFile(bashScript(gitSSH.Name(), r, params))
if err != nil {
return err
}
dir := ""
if r.pulled {
dir = r.Path
}
if err = runCmd(script.Name(), nil, dir); err == nil {
r.pulled = true
r.lastPull = time.Now()
logger().Printf("%v pulled.\n", r.URL)
r.lastCommit, err = r.getMostRecentCommit()
}
return err
}
// Prepare prepares for a git pull
// and validates the configured directory
func (r *Repo) Prepare() error {
// check if directory exists or is empty
// if not, create directory
fs, err := gos.ReadDir(r.Path)
if err != nil || len(fs) == 0 {
return gos.MkdirAll(r.Path, os.FileMode(0755))
}
// validate git repo
isGit := false
for _, f := range fs {
if f.IsDir() && f.Name() == ".git" {
isGit = true
break
}
}
if isGit {
// check if same repository
var repoURL string
if repoURL, err = r.getRepoURL(); err == nil {
// add .git suffix if missing for adequate comparison.
if !strings.HasSuffix(repoURL, ".git") {
repoURL += ".git"
}
if repoURL == r.URL {
r.pulled = true
return nil
}
}
if err != nil {
return fmt.Errorf("Cannot retrieve repo url for %v Error: %v", r.Path, err)
}
return fmt.Errorf("Another git repo '%v' exists at %v", repoURL, r.Path)
}
return fmt.Errorf("Cannot git clone into %v, directory not empty.", r.Path)
}
// getMostRecentCommit gets the hash of the most recent commit to the
// repository. Useful for checking if changes occur.
func (r *Repo) getMostRecentCommit() (string, error) {
command := gitBinary + ` --no-pager log -n 1 --pretty=format:"%H"`
c, args, err := middleware.SplitCommandAndArgs(command)
if err != nil {
return "", err
}
return runCmdOutput(c, args, r.Path)
}
// getRepoURL retrieves remote origin url for the git repository at path
func (r *Repo) getRepoURL() (string, error) {
_, err := gos.Stat(r.Path)
if err != nil {
return "", err
}
args := []string{"config", "--get", "remote.origin.url"}
return runCmdOutput(gitBinary, args, r.Path)
}
// postPullCommand executes r.Then.
// It is trigged after successful git pull
func (r *Repo) postPullCommand() error {
if r.Then == "" {
return nil
}
c, args, err := middleware.SplitCommandAndArgs(r.Then)
if err != nil {
return err
}
if err = runCmd(c, args, r.Path); err == nil {
logger().Printf("Command %v successful.\n", r.Then)
}
return err
}
// Init validates git installation, locates the git executable
// binary in PATH and check for available shell to use.
func Init() error {
// prevent concurrent call
initMutex.Lock()
defer initMutex.Unlock()
// if validation has been done before and binary located in
// PATH, return.
if gitBinary != "" {
return nil
}
// locate git binary in path
var err error
if gitBinary, err = gos.LookPath("git"); err != nil {
return fmt.Errorf("Git middleware requires git installed. Cannot find git binary in PATH")
}
// locate bash in PATH. If not found, fallback to sh.
// If neither is found, return error.
shell = "bash"
if _, err = gos.LookPath("bash"); err != nil {
shell = "sh"
if _, err = gos.LookPath("sh"); err != nil {
return fmt.Errorf("Git middleware requires either bash or sh.")
}
}
return nil
}
// runCmd is a helper function to run commands.
// It runs command with args from directory at dir.
// The executed process outputs to os.Stderr
func runCmd(command string, args []string, dir string) error {
cmd := gos.Command(command, args...)
cmd.Stdout(os.Stderr)
cmd.Stderr(os.Stderr)
cmd.Dir(dir)
if err := cmd.Start(); err != nil {
return err
}
return cmd.Wait()
}
// runCmdOutput is a helper function to run commands and return output.
// It runs command with args from directory at dir.
// If successful, returns output and nil error
func runCmdOutput(command string, args []string, dir string) (string, error) {
cmd := gos.Command(command, args...)
cmd.Dir(dir)
var err error
if output, err := cmd.Output(); err == nil {
return string(bytes.TrimSpace(output)), nil
}
return "", err
}
// writeScriptFile writes content to a temporary file.
// It changes the temporary file mode to executable and
// closes it to prepare it for execution.
func writeScriptFile(content []byte) (file gitos.File, err error) {
if file, err = gos.TempFile("", "caddy"); err != nil {
return nil, err
}
if _, err = file.Write(content); err != nil {
return nil, err
}
if err = file.Chmod(os.FileMode(0755)); err != nil {
return nil, err
}
return file, file.Close()
}
// gitWrapperScript forms content for git.sh script
func gitWrapperScript() []byte {
return []byte(fmt.Sprintf(`#!/bin/%v
# The MIT License (MIT)
# Copyright (c) 2013 Alvin Abad
if [ $# -eq 0 ]; then
echo "Git wrapper script that can specify an ssh-key file
Usage:
git.sh -i ssh-key-file git-command
"
exit 1
fi
# remove temporary file on exit
trap 'rm -f /tmp/.git_ssh.$$' 0
if [ "$1" = "-i" ]; then
SSH_KEY=$2; shift; shift
echo "ssh -i $SSH_KEY \$@" > /tmp/.git_ssh.$$
chmod +x /tmp/.git_ssh.$$
export GIT_SSH=/tmp/.git_ssh.$$
fi
# in case the git command is repeated
[ "$1" = "git" ] && shift
# Run the git command
%v "$@"
`, shell, gitBinary))
}
// bashScript forms content of bash script to clone or update a repo using ssh
func bashScript(gitShPath string, repo *Repo, params []string) []byte {
return []byte(fmt.Sprintf(`#!/bin/%v
mkdir -p ~/.ssh;
touch ~/.ssh/known_hosts;
ssh-keyscan -t rsa,dsa %v 2>&1 | sort -u - ~/.ssh/known_hosts > ~/.ssh/tmp_hosts;
cat ~/.ssh/tmp_hosts >> ~/.ssh/known_hosts;
%v -i %v %v;
`, shell, repo.Host, gitShPath, repo.KeyPath, strings.Join(params, " ")))
}