diff --git a/.drone.yml b/.drone.yml index c59e2b165..29db9066a 100644 --- a/.drone.yml +++ b/.drone.yml @@ -1,10 +1,10 @@ -image: mischief/docker-golang +image: go1.2 env: - GOROOT=/usr/local/go - GOPATH=/var/cache/drone - PATH=$GOPATH/bin:$GOPATH/bin:$PATH script: - - apt-get -y install libsqlite3-dev sqlite3 mercurial bzr 1> /dev/null 2> /dev/null + - sudo apt-get -y install libsqlite3-dev sqlite3 mercurial bzr 1> /dev/null 2> /dev/null - make deps - make - make test @@ -21,5 +21,5 @@ publish: bucket: downloads.drone.io access_key: $AWS_KEY secret_key: $AWS_SECRET - source: /tmp/drone.deb + source: deb/drone.deb target: latest/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..c03944e86 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM ubuntu:13.10 + +MAINTAINER Drone.io Team + +RUN apt-get update +RUN apt-get install -y wget gcc make g++ build-essential ca-certificates mercurial git bzr libsqlite3-dev sqlite3 + +RUN wget https://go.googlecode.com/files/go1.2.src.tar.gz && tar zxvf go1.2.src.tar.gz && cd go/src && ./make.bash + +ENV PATH $PATH:/go/bin:/gocode/bin +ENV GOPATH /gocode + +RUN mkdir -p /gocode/src/github.com/drone + +ADD . /gocode/src/github.com/drone/drone + +WORKDIR /gocode/src/github.com/drone/drone + +RUN make deps +RUN make +RUN make install + +EXPOSE 80 + +ENTRYPOINT ["/usr/local/bin/droned"] + +CMD ["--port=:80", "--path=/var/lib/drone/drone.sqlite"] diff --git a/Makefile b/Makefile index a8fe1a6a1..4be0fb074 100644 --- a/Makefile +++ b/Makefile @@ -2,19 +2,22 @@ all: embed build deps: + [ -d $$GOPATH/src/code.google.com/p/go ] || hg clone -u default https://code.google.com/p/go $$GOPATH/src/code.google.com/p/go + [ -d $$GOPATH/src/github.com/dotcloud/docker ] || git clone git://github.com/dotcloud/docker $$GOPATH/src/github.com/dotcloud/docker go get code.google.com/p/go.crypto/bcrypt go get code.google.com/p/go.crypto/ssh go get code.google.com/p/go.net/websocket go get code.google.com/p/go.text/unicode/norm + #go get code.google.com/p/go/src/pkg/archive/tar go get launchpad.net/goyaml go get github.com/andybons/hipchat go get github.com/bmizerany/pat go get github.com/dchest/authcookie go get github.com/dchest/passwordreset go get github.com/dchest/uniuri - go get github.com/dotcloud/docker/archive - go get github.com/dotcloud/docker/pkg/term - go get github.com/dotcloud/docker/utils + #go get github.com/dotcloud/docker/archive + #go get github.com/dotcloud/docker/utils + #go get github.com/dotcloud/docker/pkg/term go get github.com/drone/go-github/github go get github.com/drone/go-bitbucket/bitbucket go get github.com/GeertJohan/go.rice @@ -41,6 +44,7 @@ test: go test -v github.com/drone/drone/pkg/channel go test -v github.com/drone/drone/pkg/database go test -v github.com/drone/drone/pkg/database/encrypt + go test -v github.com/drone/drone/pkg/database/migrate go test -v github.com/drone/drone/pkg/database/testing go test -v github.com/drone/drone/pkg/mail go test -v github.com/drone/drone/pkg/model @@ -48,6 +52,7 @@ test: install: cp deb/drone/etc/init/drone.conf /etc/init/drone.conf + test -f /etc/default/drone || cp deb/drone/etc/default/drone /etc/default/drone cd bin && install -t /usr/local/bin drone cd bin && install -t /usr/local/bin droned mkdir -p /var/lib/drone @@ -64,6 +69,7 @@ clean: rm -rf usr/local/bin/drone rm -rf usr/local/bin/droned rm -rf drone.sqlite + rm -rf /tmp/drone.sqlite # creates a debian package for drone # to install `sudo dpkg -i drone.deb` @@ -75,4 +81,4 @@ dpkg: dpkg-deb --build deb/drone run: - bin/droned --port=":8080" --datasource="/tmp/drone.sqlite" \ No newline at end of file + bin/droned --port=":8080" --datasource="drone.sqlite" diff --git a/README.md b/README.md index 8e477bf5e..a3bcef814 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ -Drone is a Continuous Integration platform built on Docker +Drone is a [Continuous Integration](http://en.wikipedia.org/wiki/Continuous_integration) platform built on [Docker](https://www.docker.io/) + +[![Build Status](http://beta.drone.io/github.com/drone/drone/status.png?branch=master)](http://beta.drone.io/github.com/drone/drone) +[![GoDoc](https://godoc.org/github.com/drone/drone?status.png)](https://godoc.org/github.com/drone/drone) ### System @@ -16,13 +19,25 @@ using the following commands: ```sh $ wget http://downloads.drone.io/latest/drone.deb -$ dpkg -i drone.deb +$ sudo dpkg -i drone.deb $ sudo start drone ``` Once Drone is running (by default on :80) navigate to **http://localhost:80/install** and follow the steps in the setup wizard. +**IMPORTANT** You will also need a GitHub Client ID and Secret: + +* Register a new application https://github.com/settings/applications +* Set the homepage URL to http://$YOUR_IP_ADDRESS/ +* Set the callback URL to http://$YOUR_IP_ADDRESS/auth/login/github +* Copy the Client ID and Secret into the Drone admin console http://localhost:80/account/admin/settings + +I'm working on a getting started video. Having issues with volume, but hopefully +you can still get a feel for the steps: + +https://docs.google.com/file/d/0By8deR1ROz8memUxV0lTSGZPQUk + ### Builds Drone use a **.drone.yml** configuration file in the root of your @@ -35,7 +50,7 @@ env: script: - go build - go test -v -service: +services: - redis notify: email: @@ -60,7 +75,7 @@ image: go1.2 # same as bradrydzewski/go:1.2 Here is a list of our official images: ```sh -# these are the base images for all Drone containers. +# these two are base images. all Drone images are built on top of these # these are BIG (~3GB) so make sure you have a FAST internet connection docker pull bradrydzewski/ubuntu docker pull bradrydzewski/base @@ -135,20 +150,33 @@ if you are using a custom Docker image. Drone can launch database containers for your build: ``` -service: +services: - cassandra - couchdb + - couchdb:1.0 + - couchdb:1.4 + - couchdb:1.5 - elasticsearch + - elasticsearch:0.20 + - elasticsearch:0.90 - neo4j + - neo4j:1.9 - mongodb + - mongodb:2.2 + - mongodb:2.4 - mysql + - mysql:5.5 - postgres + - postgres:9.1 - rabbitmq + - rabbitmq:3.2 - redis - riak - zookeeper ``` +If you omit the version, Drone will launch the latest version of the database. (For example, if you set `mongodb`, Drone will launch MongoDB 2.4.) + **NOTE:** database and service containers are exposed over TCP connections and have their own local IP address. If the **socat** utility is installed inside your Docker image, Drone will automatically proxy localhost connections to the correct @@ -177,8 +205,8 @@ publish: ### Notifications -Drone can trigger email, hipchat and web hook notification at the completion -of your build: +Drone can trigger email, hipchat and web hook notification at the beginning and +completion of your build: ``` notify: @@ -192,11 +220,39 @@ notify: hipchat: room: support - token: 3028700e5466d375 + token: 3028700e5466d375 + on_started: true + on_success: true + on_failure: true ``` +### Git Command Options + +You can specify the `--depth` option of the `git clone` command (default value is `50`): + +``` +git: + depth: 1 +``` + +### Params Injection + +You can inject params into .drone.yml. + +``` +notify: + hipchat: + room: {{hipchatRoom}} + token: {{hipchatToken}} + on_started: true + on_success: true + on_failure: true +``` + +![params-injection](https://f.cloud.github.com/assets/1583973/2161187/2905077e-94c3-11e3-8499-a3844682c8af.png) + ### Docs -Coming Soon to [drone.readthedocs.org](http://drone.readthedocs.org/) - +* [drone.readthedocs.org](http://drone.readthedocs.org/) (Coming Soon) +* [GoDoc](http://godoc.org/github.com/drone/drone) diff --git a/cmd/drone/drone.go b/cmd/drone/drone.go index e3f97ee3b..9fdc6b3c7 100644 --- a/cmd/drone/drone.go +++ b/cmd/drone/drone.go @@ -164,7 +164,7 @@ func run(path string) { if len(*identity) != 0 { key, err = ioutil.ReadFile(*identity) if err != nil { - fmt.Printf("[Error] Could not find or read identity file %s\n", identity) + fmt.Printf("[Error] Could not find or read identity file %s\n", *identity) os.Exit(1) return } diff --git a/cmd/droned/assets/css/drone.css b/cmd/droned/assets/css/drone.css index 0d8684d6d..3b84a14bd 100644 --- a/cmd/droned/assets/css/drone.css +++ b/cmd/droned/assets/css/drone.css @@ -1074,3 +1074,6 @@ ul.account-radio-group li img { padding: 20px; margin-bottom: 30px; } +.url { + word-break: break-all; +} diff --git a/cmd/droned/assets/css/drone.less b/cmd/droned/assets/css/drone.less index 7820bb869..a88f913f4 100644 --- a/cmd/droned/assets/css/drone.less +++ b/cmd/droned/assets/css/drone.less @@ -1260,4 +1260,8 @@ pre { padding: 20px; margin-bottom:30px; } -} \ No newline at end of file +} + +.url { + word-break: break-all; +} diff --git a/cmd/droned/drone.go b/cmd/droned/drone.go index 6397d134e..177732221 100644 --- a/cmd/droned/drone.go +++ b/cmd/droned/drone.go @@ -15,6 +15,7 @@ import ( "github.com/drone/drone/pkg/channel" "github.com/drone/drone/pkg/database" + "github.com/drone/drone/pkg/database/migrate" "github.com/drone/drone/pkg/handler" ) @@ -55,8 +56,9 @@ func main() { // setup the database connection and register with the // global database package. func setupDatabase() { - // inform meddler we're using sqlite + // inform meddler and migration we're using sqlite meddler.Default = meddler.SQLite + migrate.Driver = migrate.SQLite // connect to the SQLite database db, err := sql.Open(driver, datasource) @@ -65,6 +67,9 @@ func setupDatabase() { } database.Set(db) + + migration := migrate.New(db) + migration.All().Migrate() } // setup routes for static assets. These assets may @@ -73,7 +78,18 @@ func setupDatabase() { func setupStatic() { box := rice.MustFindBox("assets") http.Handle("/css/", http.FileServer(box.HTTPBox())) - http.Handle("/img/", http.FileServer(box.HTTPBox())) + + // we need to intercept all attempts to serve images + // so that we can add a cache-control settings + var images = http.FileServer(box.HTTPBox()) + http.HandleFunc("/img/", func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/img/build_") { + w.Header().Add("Cache-Control", "no-cache") + } + + // serce images + images.ServeHTTP(w, r) + }) } // setup routes for serving dynamic content. @@ -86,6 +102,8 @@ func setupHandlers() { m.Post("/forgot", handler.ErrorHandler(handler.ForgotPost)) m.Get("/reset", handler.ErrorHandler(handler.Reset)) m.Post("/reset", handler.ErrorHandler(handler.ResetPost)) + m.Get("/signup", handler.ErrorHandler(handler.SignUp)) + m.Post("/signup", handler.ErrorHandler(handler.SignUpPost)) m.Get("/register", handler.ErrorHandler(handler.Register)) m.Post("/register", handler.ErrorHandler(handler.RegisterPost)) m.Get("/accept", handler.UserHandler(handler.TeamMemberAccept)) @@ -145,8 +163,7 @@ func setupHandlers() { m.Get("/:host/:owner/:name/commit/:commit/build/:label/out.txt", handler.RepoHandler(handler.BuildOut)) m.Get("/:host/:owner/:name/commit/:commit/build/:label", handler.RepoHandler(handler.CommitShow)) m.Get("/:host/:owner/:name/commit/:commit", handler.RepoHandler(handler.CommitShow)) - m.Get("/:host/:owner/:name/tree/:branch/status.png", handler.ErrorHandler(handler.Badge)) - m.Get("/:host/:owner/:name/tree/:branch", handler.RepoHandler(handler.RepoDashboard)) + m.Get("/:host/:owner/:name/tree", handler.RepoHandler(handler.RepoDashboard)) m.Get("/:host/:owner/:name/status.png", handler.ErrorHandler(handler.Badge)) m.Get("/:host/:owner/:name/settings", handler.RepoAdminHandler(handler.RepoSettingsForm)) m.Get("/:host/:owner/:name/params", handler.RepoAdminHandler(handler.RepoParamsForm)) @@ -165,8 +182,6 @@ func setupHandlers() { // the first time a page is requested we should record // the scheme and hostname. http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // get the hostname and scheme - // our multiplexer is a bit finnicky and therefore requires // us to strip any trailing slashes in order to correctly // find and match a route. diff --git a/deb/drone/DEBIAN/conffiles b/deb/drone/DEBIAN/conffiles new file mode 100644 index 000000000..fb1baab83 --- /dev/null +++ b/deb/drone/DEBIAN/conffiles @@ -0,0 +1,2 @@ +/etc/init/drone.conf +/etc/default/drone diff --git a/deb/drone/DEBIAN/postinst b/deb/drone/DEBIAN/postinst new file mode 100755 index 000000000..776f572b1 --- /dev/null +++ b/deb/drone/DEBIAN/postinst @@ -0,0 +1,24 @@ +#!/bin/sh +set -e + +case "$1" in + abort-upgrade|abort-remove|abort-deconfigure|configure) + ;; + + *) + echo "postinst called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +echo "Starting drone ..." +if [ -f /etc/init/drone.conf ]; then + if pidof /usr/local/bin/droned >/dev/null; then + service drone stop || exit $? + fi + service drone start && echo "Drone started." +fi + +#DEBHELPER# + +exit 0 diff --git a/deb/drone/DEBIAN/prerm b/deb/drone/DEBIAN/prerm new file mode 100755 index 000000000..a0f7be8cb --- /dev/null +++ b/deb/drone/DEBIAN/prerm @@ -0,0 +1,26 @@ +#!/bin/sh + +set -e +set -u + +case "$1" in + remove|remove-in-favour|deconfigure|deconfigure-in-favour) + if [ -f /etc/init/drone.conf ]; then + echo "Stopping drone ..." + service drone stop || exit $? + echo "Drone Stopped." + fi + ;; + + upgrade|failed-upgrade) + ;; + + *) + echo "prerm called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +#DEBHELPER# + +exit 0 diff --git a/deb/drone/etc/default/drone b/deb/drone/etc/default/drone new file mode 100644 index 000000000..1eaa2679b --- /dev/null +++ b/deb/drone/etc/default/drone @@ -0,0 +1,10 @@ +# Upstart configuration file for droned. + +# Command line options: +# +# -datasource="drone.sqlite": +# -driver="sqlite3": +# -path="": +# -port=":8080": +# +#DRONED_OPTS="--port=:80" diff --git a/deb/drone/etc/init/drone.conf b/deb/drone/etc/init/drone.conf index 102b2bc34..6cae6ac34 100644 --- a/deb/drone/etc/init/drone.conf +++ b/deb/drone/etc/init/drone.conf @@ -4,5 +4,9 @@ chdir /var/lib/drone console log script - droned --port=":80" -end script \ No newline at end of file + DRONED_OPTS="--port=:80" + if [ -f /etc/default/$UPSTART_JOB ]; then + . /etc/default/$UPSTART_JOB + fi + droned $DRONED_OPTS +end script diff --git a/pkg/build/docker/client.go b/pkg/build/docker/client.go index 1bfcbd660..bb33a28fb 100644 --- a/pkg/build/docker/client.go +++ b/pkg/build/docker/client.go @@ -32,15 +32,8 @@ var Logging = true // New creates an instance of the Docker Client func New() *Client { c := &Client{} - c.proto = DEFAULTPROTOCOL - c.addr = DEFAULTUNIXSOCKET - // if the default socket doesn't exist then - // we'll try to connect to the default tcp address - if _, err := os.Stat(DEFAULTUNIXSOCKET); err != nil { - c.proto = "tcp" - c.addr = "0.0.0.0:4243" - } + c.setHost(DEFAULTUNIXSOCKET) c.Images = &ImageService{c} c.Containers = &ContainerService{c} @@ -76,6 +69,26 @@ var ( ErrBadRequest = errors.New("Bad Request") ) +func (c *Client) setHost(defaultUnixSocket string) { + c.proto = DEFAULTPROTOCOL + c.addr = defaultUnixSocket + + if os.Getenv("DOCKER_HOST") != "" { + pieces := strings.Split(os.Getenv("DOCKER_HOST"), "://") + if len(pieces) == 2 { + c.proto = pieces[0] + c.addr = pieces[1] + } + } else { + // if the default socket doesn't exist then + // we'll try to connect to the default tcp address + if _, err := os.Stat(defaultUnixSocket); err != nil { + c.proto = "tcp" + c.addr = "0.0.0.0:4243" + } + } +} + // helper function used to make HTTP requests to the Docker daemon. func (c *Client) do(method, path string, in, out interface{}) error { // if data input is provided, serialize to JSON diff --git a/pkg/build/docker/client_test.go b/pkg/build/docker/client_test.go new file mode 100644 index 000000000..6869fddcc --- /dev/null +++ b/pkg/build/docker/client_test.go @@ -0,0 +1,67 @@ +package docker + +import ( + "io/ioutil" + "os" + "testing" +) + +func TestHostFromEnv(t *testing.T) { + os.Setenv("DOCKER_HOST", "tcp://1.1.1.1:4243") + defer os.Setenv("DOCKER_HOST", "") + + client := New() + + if client.proto != "tcp" { + t.Fail() + } + + if client.addr != "1.1.1.1:4243" { + t.Fail() + } +} + +func TestInvalidHostFromEnv(t *testing.T) { + os.Setenv("DOCKER_HOST", "tcp:1.1.1.1:4243") // missing tcp:// prefix + defer os.Setenv("DOCKER_HOST", "") + + client := New() + + if client.addr == "1.1.1.1:4243" { + t.Fail() + } +} + +func TestSocketHost(t *testing.T) { + // create temporary file to represent the docker socket + file, err := ioutil.TempFile("", "TestDefaultUnixHost") + if err != nil { + t.Fail() + } + file.Close() + defer os.Remove(file.Name()) + + client := &Client{} + client.setHost(file.Name()) + + if client.proto != "unix" { + t.Fail() + } + + if client.addr != file.Name() { + t.Fail() + } +} + +func TestDefaultTcpHost(t *testing.T) { + client := &Client{} + client.setHost("/tmp/missing_socket") + + if client.proto != "tcp" { + t.Fail() + } + + if client.addr != "0.0.0.0:4243" { + t.Fail() + } +} diff --git a/pkg/build/docker/image.go b/pkg/build/docker/image.go index 259128f86..ca5eb0528 100644 --- a/pkg/build/docker/image.go +++ b/pkg/build/docker/image.go @@ -59,7 +59,7 @@ func (c *ImageService) List() ([]*Images, error) { // Create an image, either by pull it from the registry or by importing it. func (c *ImageService) Create(image string) error { - return c.do("POST", fmt.Sprintf("/images/create?fromImage=%s"), nil, nil) + return c.do("POST", fmt.Sprintf("/images/create?fromImage=%s", image), nil, nil) } func (c *ImageService) Pull(image string) error { @@ -110,7 +110,7 @@ func (c *ImageService) Build(tag, dir string) error { v := url.Values{} v.Set("t", tag) v.Set("q", "1") - //v.Set("rm", "1") + v.Set("rm", "1") // url path path := fmt.Sprintf("/build?%s", v.Encode()) @@ -120,5 +120,5 @@ func (c *ImageService) Build(tag, dir string) error { headers.Set("Content-Type", "application/tar") // make the request - return c.stream("POST", path, body, nil, headers) + return c.stream("POST", path, body, os.Stdout, headers) } diff --git a/pkg/build/git/git.go b/pkg/build/git/git.go new file mode 100644 index 000000000..36e67f13e --- /dev/null +++ b/pkg/build/git/git.go @@ -0,0 +1,31 @@ +package git + +const ( + DefaultGitDepth = 50 +) + +// Git stores the configuration details for +// executing Git commands. +type Git struct { + // Depth options instructs git to create a shallow + // clone with a history truncated to the specified + // number of revisions. + Depth *int `yaml:"depth,omitempty"` + + // The name of a directory to clone into. + // TODO this still needs to be implemented. this field is + // critical for forked Go projects, that need to clone + // to a specific repository. + Path string `yaml:"path,omitempty"` +} + +// GitDepth returns GitDefaultDepth +// when Git.Depth is empty. +// GitDepth returns Git.Depth +// when it is not empty. +func GitDepth(g *Git) int { + if g == nil || g.Depth == nil { + return DefaultGitDepth + } + return *g.Depth +} diff --git a/pkg/build/git/git_test.go b/pkg/build/git/git_test.go new file mode 100644 index 000000000..23eb395ba --- /dev/null +++ b/pkg/build/git/git_test.go @@ -0,0 +1,40 @@ +package git + +import ( + "testing" +) + +func TestGitDepth(t *testing.T) { + var g *Git + var expected int + + expected = DefaultGitDepth + g = nil + if actual := GitDepth(g); actual != expected { + t.Errorf("The result is invalid. [expected: %d][actual: %d]", expected, actual) + } + + expected = DefaultGitDepth + g = &Git{} + if actual := GitDepth(g); actual != expected { + t.Errorf("The result is invalid. [expected: %d][actual: %d]", expected, actual) + } + + expected = DefaultGitDepth + g = &Git{Depth: nil} + if actual := GitDepth(g); actual != expected { + t.Errorf("The result is invalid. [expected: %d][actual: %d]", expected, actual) + } + + expected = 0 + g = &Git{Depth: &expected} + if actual := GitDepth(g); actual != expected { + t.Errorf("The result is invalid. [expected: %d][actual: %d]", expected, actual) + } + + expected = 1 + g = &Git{Depth: &expected} + if actual := GitDepth(g); actual != expected { + t.Errorf("The result is invalid. [expected: %d][actual: %d]", expected, actual) + } +} diff --git a/pkg/build/images.go b/pkg/build/images.go index d887ee367..5c09820b1 100644 --- a/pkg/build/images.go +++ b/pkg/build/images.go @@ -116,7 +116,7 @@ var services = map[string]*image{ // couchdb "couchdb": { Ports: []string{"5984"}, - Tag: "bradrydzewski/couchdb:1.0", + Tag: "bradrydzewski/couchdb:1.5", Name: "couchdb", }, "couchdb:1.0": { diff --git a/pkg/build/repo/repo.go b/pkg/build/repo/repo.go index 581be9688..d1af38fac 100644 --- a/pkg/build/repo/repo.go +++ b/pkg/build/repo/repo.go @@ -33,6 +33,9 @@ type Repo struct { // will be cloned into (or copied to) inside the // host system (Docker Container). Dir string + + // (optional) The depth of the `git clone` command. + Depth int } // IsRemote returns true if the Repository is located @@ -70,9 +73,9 @@ func (r *Repo) IsGit() bool { return true case strings.HasPrefix(r.Path, "ssh://git@"): return true - case strings.HasPrefix(r.Path, "https://github.com/"): + case strings.HasPrefix(r.Path, "https://github"): return true - case strings.HasPrefix(r.Path, "http://github.com"): + case strings.HasPrefix(r.Path, "http://github"): return true case strings.HasSuffix(r.Path, ".git"): return true @@ -88,7 +91,6 @@ func (r *Repo) IsGit() bool { // // TODO we should also enable Mercurial projects and SVN projects func (r *Repo) Commands() []string { - // get the branch. default to master // if no branch exists. branch := r.Branch @@ -97,7 +99,7 @@ func (r *Repo) Commands() []string { } cmds := []string{} - cmds = append(cmds, fmt.Sprintf("git clone --branch=%s %s %s", branch, r.Path, r.Dir)) + cmds = append(cmds, fmt.Sprintf("git clone --depth=%d --recursive --branch=%s %s %s", r.Depth, branch, r.Path, r.Dir)) switch { // if a specific commit is provided then we'll diff --git a/pkg/build/script/deployment/appfog.go b/pkg/build/script/deployment/appfog.go deleted file mode 100644 index 367fd2709..000000000 --- a/pkg/build/script/deployment/appfog.go +++ /dev/null @@ -1,12 +0,0 @@ -package deployment - -import ( - "github.com/drone/drone/pkg/build/buildfile" -) - -type AppFog struct { -} - -func (a *AppFog) Write(f *buildfile.Buildfile) { - -} diff --git a/pkg/build/script/deployment/cloudcontrol.go b/pkg/build/script/deployment/cloudcontrol.go deleted file mode 100644 index 881410617..000000000 --- a/pkg/build/script/deployment/cloudcontrol.go +++ /dev/null @@ -1,12 +0,0 @@ -package deployment - -import ( - "github.com/drone/drone/pkg/build/buildfile" -) - -type CloudControl struct { -} - -func (c *CloudControl) Write(f *buildfile.Buildfile) { - -} diff --git a/pkg/build/script/deployment/cloudfoundry.go b/pkg/build/script/deployment/cloudfoundry.go deleted file mode 100644 index 1f4620818..000000000 --- a/pkg/build/script/deployment/cloudfoundry.go +++ /dev/null @@ -1,12 +0,0 @@ -package deployment - -import ( - "github.com/drone/drone/pkg/build/buildfile" -) - -type CloudFoundry struct { -} - -func (c *CloudFoundry) Write(f *buildfile.Buildfile) { - -} diff --git a/pkg/build/script/deployment/deployment.go b/pkg/build/script/deployment/deployment.go deleted file mode 100644 index eb26dbc2b..000000000 --- a/pkg/build/script/deployment/deployment.go +++ /dev/null @@ -1,42 +0,0 @@ -package deployment - -import ( - "github.com/drone/drone/pkg/build/buildfile" -) - -// Deploy stores the configuration details -// for deploying build artifacts when -// a Build has succeeded -type Deploy struct { - AppFog *AppFog `yaml:"appfog,omitempty"` - CloudControl *CloudControl `yaml:"cloudcontrol,omitempty"` - CloudFoundry *CloudFoundry `yaml:"cloudfoundry,omitempty"` - EngineYard *EngineYard `yaml:"engineyard,omitempty"` - Heroku *Heroku `yaml:"heroku,omitempty"` - Nodejitsu *Nodejitsu `yaml:"nodejitsu,omitempty"` - Openshift *Openshift `yaml:"openshift,omitempty"` -} - -func (d *Deploy) Write(f *buildfile.Buildfile) { - if d.AppFog != nil { - d.AppFog.Write(f) - } - if d.CloudControl != nil { - d.CloudControl.Write(f) - } - if d.CloudFoundry != nil { - d.CloudFoundry.Write(f) - } - if d.EngineYard != nil { - d.EngineYard.Write(f) - } - if d.Heroku != nil { - d.Heroku.Write(f) - } - if d.Nodejitsu != nil { - d.Nodejitsu.Write(f) - } - if d.Openshift != nil { - d.Openshift.Write(f) - } -} diff --git a/pkg/build/script/deployment/engineyard.go b/pkg/build/script/deployment/engineyard.go deleted file mode 100644 index 8aefa93d1..000000000 --- a/pkg/build/script/deployment/engineyard.go +++ /dev/null @@ -1,12 +0,0 @@ -package deployment - -import ( - "github.com/drone/drone/pkg/build/buildfile" -) - -type EngineYard struct { -} - -func (e *EngineYard) Write(f *buildfile.Buildfile) { - -} diff --git a/pkg/build/script/deployment/git.go b/pkg/build/script/deployment/git.go deleted file mode 100644 index 3c65985a3..000000000 --- a/pkg/build/script/deployment/git.go +++ /dev/null @@ -1 +0,0 @@ -package deployment diff --git a/pkg/build/script/deployment/heroku.go b/pkg/build/script/deployment/heroku.go deleted file mode 100644 index 60dd2e371..000000000 --- a/pkg/build/script/deployment/heroku.go +++ /dev/null @@ -1,38 +0,0 @@ -package deployment - -import ( - "fmt" - "github.com/drone/drone/pkg/build/buildfile" -) - -type Heroku struct { - App string `yaml:"app,omitempty"` - Force bool `yaml:"force,omitempty"` - Branch string `yaml:"branch,omitempty"` -} - -func (h *Heroku) Write(f *buildfile.Buildfile) { - // get the current commit hash - f.WriteCmdSilent("COMMIT=$(git rev-parse HEAD)") - - // set the git user and email based on the individual - // that made the commit. - f.WriteCmdSilent("git config --global user.name $(git --no-pager log -1 --pretty=format:'%an')") - f.WriteCmdSilent("git config --global user.email $(git --no-pager log -1 --pretty=format:'%ae')") - - // add heroku as a git remote - f.WriteCmd(fmt.Sprintf("git remote add heroku git@heroku.com:%s.git", h.App)) - - switch h.Force { - case true: - // this is useful when the there are artifacts generated - // by the build script, such as less files converted to css, - // that need to be deployed to Heroku. - f.WriteCmd(fmt.Sprintf("git add -A")) - f.WriteCmd(fmt.Sprintf("git commit -m 'adding build artifacts'")) - f.WriteCmd(fmt.Sprintf("git push heroku $COMMIT:master --force")) - case false: - // otherwise we just do a standard git push - f.WriteCmd(fmt.Sprintf("git push heroku $COMMIT:master")) - } -} diff --git a/pkg/build/script/deployment/nodejitsu.go b/pkg/build/script/deployment/nodejitsu.go deleted file mode 100644 index 6a0af8a3a..000000000 --- a/pkg/build/script/deployment/nodejitsu.go +++ /dev/null @@ -1,12 +0,0 @@ -package deployment - -import ( - "github.com/drone/drone/pkg/build/buildfile" -) - -type Nodejitsu struct { -} - -func (n *Nodejitsu) Write(f *buildfile.Buildfile) { - -} diff --git a/pkg/build/script/deployment/openshift.go b/pkg/build/script/deployment/openshift.go deleted file mode 100644 index dc325c742..000000000 --- a/pkg/build/script/deployment/openshift.go +++ /dev/null @@ -1,12 +0,0 @@ -package deployment - -import ( - "github.com/drone/drone/pkg/build/buildfile" -) - -type Openshift struct { -} - -func (o *Openshift) Write(f *buildfile.Buildfile) { - -} diff --git a/pkg/build/script/deployment/ssh.go b/pkg/build/script/deployment/ssh.go deleted file mode 100644 index 3c65985a3..000000000 --- a/pkg/build/script/deployment/ssh.go +++ /dev/null @@ -1 +0,0 @@ -package deployment diff --git a/pkg/build/script/notification/email.go b/pkg/build/script/notification/email.go deleted file mode 100644 index cdb39932b..000000000 --- a/pkg/build/script/notification/email.go +++ /dev/null @@ -1,85 +0,0 @@ -package notification - -import ( - "fmt" - "net/smtp" -) - -type Email struct { - Recipients []string `yaml:"recipients,omitempty"` - Success string `yaml:"on_success"` - Failure string `yaml:"on_failure"` - - host string // smtp host address - port string // smtp host port - user string // smtp username for authentication - pass string // smtp password for authentication - from string // smtp email address. send from this address -} - -// SetServer is a function that will set the SMTP -// server location and credentials -func (e *Email) SetServer(host, port, user, pass, from string) { - e.host = host - e.port = port - e.user = user - e.pass = pass - e.from = from -} - -// Send will send an email, either success or failure, -// based on the Commit Status. -func (e *Email) Send(context *Context) error { - switch { - case context.Commit.Status == "Success" && e.Success != "never": - return e.sendSuccess(context) - case context.Commit.Status == "Failure" && e.Failure != "never": - return e.sendFailure(context) - } - - return nil -} - -// sendFailure sends email notifications to the list of -// recipients indicating the build failed. -func (e *Email) sendFailure(context *Context) error { - // loop through and email recipients - /*for _, email := range e.Recipients { - if err := mail.SendFailure(context.Repo.Slug, email, context); err != nil { - return err - } - }*/ - return nil -} - -// sendSuccess sends email notifications to the list of -// recipients indicating the build was a success. -func (e *Email) sendSuccess(context *Context) error { - // loop through and email recipients - /*for _, email := range e.Recipients { - if err := mail.SendSuccess(context.Repo.Slug, email, context); err != nil { - return err - } - }*/ - return nil -} - -// send is a simple helper function to format and -// send an email message. -func (e *Email) send(to, subject, body string) error { - // Format the raw email message body - raw := fmt.Sprintf(emailTemplate, e.from, to, subject, body) - auth := smtp.PlainAuth("", e.user, e.pass, e.host) - addr := fmt.Sprintf("%s:%s", e.host, e.port) - - return smtp.SendMail(addr, auth, e.from, []string{to}, []byte(raw)) -} - -// text-template used to generate a raw Email message -var emailTemplate = `From: %s -To: %s -Subject: %s -MIME-version: 1.0 -Content-Type: text/html; charset="UTF-8" - -%s` diff --git a/pkg/build/script/notification/hipchat.go b/pkg/build/script/notification/hipchat.go deleted file mode 100644 index 5e8def651..000000000 --- a/pkg/build/script/notification/hipchat.go +++ /dev/null @@ -1,64 +0,0 @@ -package notification - -import ( - "fmt" - - "github.com/andybons/hipchat" -) - -const ( - startedMessage = "Building %s, commit %s, author %s" - successMessage = "Success %s, commit %s, author %s" - failureMessage = "Failed %s, commit %s, author %s" -) - -type Hipchat struct { - Room string `yaml:"room,omitempty"` - Token string `yaml:"token,omitempty"` - Started bool `yaml:"on_started,omitempty"` - Success bool `yaml:"on_success,omitempty"` - Failure bool `yaml:"on_failure,omitempty"` -} - -func (h *Hipchat) Send(context *Context) error { - switch { - case context.Commit.Status == "Started" && h.Started: - return h.sendStarted(context) - case context.Commit.Status == "Success" && h.Success: - return h.sendSuccess(context) - case context.Commit.Status == "Failure" && h.Failure: - return h.sendFailure(context) - } - - return nil -} - -func (h *Hipchat) sendStarted(context *Context) error { - msg := fmt.Sprintf(startedMessage, context.Repo.Name, context.Commit.HashShort(), context.Commit.Author) - return h.send(hipchat.ColorYellow, hipchat.FormatHTML, msg) -} - -func (h *Hipchat) sendFailure(context *Context) error { - msg := fmt.Sprintf(failureMessage, context.Repo.Name, context.Commit.HashShort(), context.Commit.Author) - return h.send(hipchat.ColorRed, hipchat.FormatHTML, msg) -} - -func (h *Hipchat) sendSuccess(context *Context) error { - msg := fmt.Sprintf(successMessage, context.Repo.Name, context.Commit.HashShort(), context.Commit.Author) - return h.send(hipchat.ColorGreen, hipchat.FormatHTML, msg) -} - -// helper function to send Hipchat requests -func (h *Hipchat) send(color, format, message string) error { - c := hipchat.Client{AuthToken: h.Token} - req := hipchat.MessageRequest{ - RoomId: h.Room, - From: "Drone", - Message: message, - Color: color, - MessageFormat: format, - Notify: true, - } - - return c.PostMessage(req) -} diff --git a/pkg/build/script/notification/irc.go b/pkg/build/script/notification/irc.go deleted file mode 100644 index 4306c87f1..000000000 --- a/pkg/build/script/notification/irc.go +++ /dev/null @@ -1 +0,0 @@ -package notification diff --git a/pkg/build/script/notification/notification.go b/pkg/build/script/notification/notification.go deleted file mode 100644 index bea1b6e71..000000000 --- a/pkg/build/script/notification/notification.go +++ /dev/null @@ -1,53 +0,0 @@ -package notification - -import ( - "github.com/drone/drone/pkg/model" -) - -// Context represents the context of an -// in-progress build request. -type Context struct { - // Global settings - Host string - - // User that owns the repository - User *model.User - - // Repository being built. - Repo *model.Repo - - // Commit being built - Commit *model.Commit -} - -type Sender interface { - Send(context *Context) error -} - -// Notification stores the configuration details -// for notifying a user, or group of users, -// when their Build has completed. -type Notification struct { - Email *Email `yaml:"email,omitempty"` - Webhook *Webhook `yaml:"webhook,omitempty"` - Hipchat *Hipchat `yaml:"hipchat,omitempty"` -} - -func (n *Notification) Send(context *Context) error { - // send email notifications - //if n.Email != nil && n.Email.Enabled { - // n.Email.Send(context) - //} - - // send email notifications - if n.Webhook != nil { - n.Webhook.Send(context) - } - - // send email notifications - if n.Hipchat != nil { - n.Hipchat.Send(context) - } - - return nil -} diff --git a/pkg/build/script/notification/webhook.go b/pkg/build/script/notification/webhook.go deleted file mode 100644 index 649fbbe2c..000000000 --- a/pkg/build/script/notification/webhook.go +++ /dev/null @@ -1,59 +0,0 @@ -package notification - -import ( - "bytes" - "encoding/json" - "net/http" - - "github.com/drone/drone/pkg/model" -) - -type Webhook struct { - URL []string `yaml:"urls,omitempty"` - Success bool `yaml:"on_success,omitempty"` - Failure bool `yaml:"on_failure,omitempty"` -} - -func (w *Webhook) Send(context *Context) error { - switch { - case context.Commit.Status == "Success" && w.Success: - return w.send(context) - case context.Commit.Status == "Failure" && w.Failure: - return w.send(context) - } - - return nil -} - -// helper function to send HTTP requests -func (w *Webhook) send(context *Context) error { - // data will get posted in this format - data := struct { - Owner *model.User `json:"owner"` - Repo *model.Repo `json:"repository"` - Commit *model.Commit `json:"commit"` - }{context.User, context.Repo, context.Commit} - - // data json encoded - payload, err := json.Marshal(data) - if err != nil { - return err - } - - // loop through and email recipients - for _, url := range w.URL { - go sendJson(url, payload) - } - return nil -} - -// helper fuction to sent HTTP Post requests -// with JSON data as the payload. -func sendJson(url string, payload []byte) { - buf := bytes.NewBuffer(payload) - resp, err := http.Post(url, "application/json", buf) - if err != nil { - return - } - resp.Body.Close() -} diff --git a/pkg/build/script/notification/zapier.go b/pkg/build/script/notification/zapier.go deleted file mode 100644 index 4306c87f1..000000000 --- a/pkg/build/script/notification/zapier.go +++ /dev/null @@ -1 +0,0 @@ -package notification diff --git a/pkg/build/script/publish/bintray.go b/pkg/build/script/publish/bintray.go deleted file mode 100644 index 30b1a5b2a..000000000 --- a/pkg/build/script/publish/bintray.go +++ /dev/null @@ -1 +0,0 @@ -package publish diff --git a/pkg/build/script/publish/dropbox.go b/pkg/build/script/publish/dropbox.go deleted file mode 100644 index 30b1a5b2a..000000000 --- a/pkg/build/script/publish/dropbox.go +++ /dev/null @@ -1 +0,0 @@ -package publish diff --git a/pkg/build/script/publish/gems.go b/pkg/build/script/publish/gems.go deleted file mode 100644 index 30b1a5b2a..000000000 --- a/pkg/build/script/publish/gems.go +++ /dev/null @@ -1 +0,0 @@ -package publish diff --git a/pkg/build/script/publish/maven.go b/pkg/build/script/publish/maven.go deleted file mode 100644 index 30b1a5b2a..000000000 --- a/pkg/build/script/publish/maven.go +++ /dev/null @@ -1 +0,0 @@ -package publish diff --git a/pkg/build/script/publish/npm.go b/pkg/build/script/publish/npm.go deleted file mode 100644 index 30b1a5b2a..000000000 --- a/pkg/build/script/publish/npm.go +++ /dev/null @@ -1 +0,0 @@ -package publish diff --git a/pkg/build/script/publish/pub.go b/pkg/build/script/publish/pub.go deleted file mode 100644 index 30b1a5b2a..000000000 --- a/pkg/build/script/publish/pub.go +++ /dev/null @@ -1 +0,0 @@ -package publish diff --git a/pkg/build/script/publish/publish.go b/pkg/build/script/publish/publish.go deleted file mode 100644 index d31088f29..000000000 --- a/pkg/build/script/publish/publish.go +++ /dev/null @@ -1,18 +0,0 @@ -package publish - -import ( - "github.com/drone/drone/pkg/build/buildfile" -) - -// Publish stores the configuration details -// for publishing build artifacts when -// a Build has succeeded -type Publish struct { - S3 *S3 `yaml:"s3,omitempty"` -} - -func (p *Publish) Write(f *buildfile.Buildfile) { - if p.S3 != nil { - p.S3.Write(f) - } -} diff --git a/pkg/build/script/publish/pypi.go b/pkg/build/script/publish/pypi.go deleted file mode 100644 index d46cd60ca..000000000 --- a/pkg/build/script/publish/pypi.go +++ /dev/null @@ -1,2 +0,0 @@ -package publish - diff --git a/pkg/build/script/publish/s3.go b/pkg/build/script/publish/s3.go deleted file mode 100644 index cfa75e7d1..000000000 --- a/pkg/build/script/publish/s3.go +++ /dev/null @@ -1,85 +0,0 @@ -package publish - -import ( - "fmt" - "strings" - - "github.com/drone/drone/pkg/build/buildfile" -) - -type S3 struct { - Key string `yaml:"access_key,omitempty"` - Secret string `yaml:"secret_key,omitempty"` - Bucket string `yaml:"bucket,omitempty"` - - // us-east-1 - // us-west-1 - // us-west-2 - // eu-west-1 - // ap-southeast-1 - // ap-southeast-2 - // ap-northeast-1 - // sa-east-1 - Region string `yaml:"region,omitempty"` - - // Indicates the files ACL, which should be one - // of the following: - // private - // public-read - // public-read-write - // authenticated-read - // bucket-owner-read - // bucket-owner-full-control - Access string `yaml:"acl,omitempty"` - - // Copies the files from the specified directory. - // Regexp matching will apply to match multiple - // files - // - // Examples: - // /path/to/file - // /path/to/*.txt - // /path/to/*/*.txt - // /path/to/** - Source string `yaml:"source,omitempty"` - Target string `yaml:"target,omitempty"` - - // Recursive uploads - Recursive bool `yaml:"recursive"` - - Branch string `yaml:"branch,omitempty"` -} - -func (s *S3) Write(f *buildfile.Buildfile) { - // install the AWS cli using PIP - f.WriteCmdSilent("[ -f /usr/bin/sudo ] || pip install awscli 1> /dev/null 2> /dev/null") - f.WriteCmdSilent("[ -f /usr/bin/sudo ] && sudo pip install awscli 1> /dev/null 2> /dev/null") - - f.WriteEnv("AWS_ACCESS_KEY_ID", s.Key) - f.WriteEnv("AWS_SECRET_ACCESS_KEY", s.Secret) - - // make sure a default region is set - if len(s.Region) == 0 { - s.Region = "us-east-1" - } - - // make sure a default access is set - // let's be conservative and assume private - if len(s.Region) == 0 { - s.Region = "private" - } - - // if the target starts with a "/" we need - // to remove it, otherwise we might adding - // a 3rd slash to s3:// - if strings.HasPrefix(s.Target, "/") { - s.Target = s.Target[1:] - } - - switch s.Recursive { - case true: - f.WriteCmd(fmt.Sprintf(`aws s3 cp %s s3://%s/%s --recursive --acl %s --region %s`, s.Source, s.Bucket, s.Target, s.Access, s.Region)) - case false: - f.WriteCmd(fmt.Sprintf(`aws s3 cp %s s3://%s/%s --acl %s --region %s`, s.Source, s.Bucket, s.Target, s.Access, s.Region)) - } -} diff --git a/pkg/build/script/report/README.md b/pkg/build/script/report/README.md deleted file mode 100644 index 03260a5b1..000000000 --- a/pkg/build/script/report/README.md +++ /dev/null @@ -1,5 +0,0 @@ -cobertura.go -coveralls.go -gocov.go -junit.go -phpunit.go \ No newline at end of file diff --git a/pkg/build/script/script.go b/pkg/build/script/script.go index 72af02a0f..032df3844 100644 --- a/pkg/build/script/script.go +++ b/pkg/build/script/script.go @@ -1,22 +1,25 @@ package script import ( + "bytes" + "fmt" "io/ioutil" "strings" "launchpad.net/goyaml" "github.com/drone/drone/pkg/build/buildfile" - "github.com/drone/drone/pkg/build/script/deployment" - "github.com/drone/drone/pkg/build/script/notification" - "github.com/drone/drone/pkg/build/script/publish" + "github.com/drone/drone/pkg/build/git" + "github.com/drone/drone/pkg/plugin/deploy" + "github.com/drone/drone/pkg/plugin/notify" + "github.com/drone/drone/pkg/plugin/publish" ) -func ParseBuild(data []byte) (*Build, error) { +func ParseBuild(data []byte, params map[string]string) (*Build, error) { build := Build{} // parse the build configuration file - err := goyaml.Unmarshal(data, &build) + err := goyaml.Unmarshal(injectParams(data, params), &build) return &build, err } @@ -26,7 +29,15 @@ func ParseBuildFile(filename string) (*Build, error) { return nil, err } - return ParseBuild(data) + return ParseBuild(data, nil) +} + +// injectParams injects params into data. +func injectParams(data []byte, params map[string]string) []byte { + for k, v := range params { + data = bytes.Replace(data, []byte(fmt.Sprintf("{{%s}}", k)), []byte(v), -1) + } + return data } // Build stores the configuration details for @@ -51,9 +62,10 @@ type Build struct { // linked to the build environment. Services []string - Deploy *deployment.Deploy `yaml:"deploy,omitempty"` - Publish *publish.Publish `yaml:"publish,omitempty"` - Notifications *notification.Notification `yaml:"notify,omitempty"` + Deploy *deploy.Deploy `yaml:"deploy,omitempty"` + Publish *publish.Publish `yaml:"publish,omitempty"` + Notifications *notify.Notification `yaml:"notify,omitempty"` + Git *git.Git `yaml:"git,omitempty"` } // Write adds all the steps to the build script, including diff --git a/pkg/database/migrate/201402200603_rename_privelege_to_privilege.go b/pkg/database/migrate/201402200603_rename_privelege_to_privilege.go new file mode 100644 index 000000000..379a6649e --- /dev/null +++ b/pkg/database/migrate/201402200603_rename_privelege_to_privilege.go @@ -0,0 +1,23 @@ +package migrate + +type Rev1 struct{} + +var RenamePrivelegedToPrivileged = &Rev1{} + +func (r *Rev1) Revision() int64 { + return 201402200603 +} + +func (r *Rev1) Up(op Operation) error { + _, err := op.RenameColumns("repos", map[string]string{ + "priveleged": "privileged", + }) + return err +} + +func (r *Rev1) Down(op Operation) error { + _, err := op.RenameColumns("repos", map[string]string{ + "privileged": "priveleged", + }) + return err +} diff --git a/pkg/database/migrate/201402211147_github_enterprise_support.go b/pkg/database/migrate/201402211147_github_enterprise_support.go new file mode 100644 index 000000000..dcdd5e8f1 --- /dev/null +++ b/pkg/database/migrate/201402211147_github_enterprise_support.go @@ -0,0 +1,26 @@ +package migrate + +type Rev3 struct{} + +var GitHubEnterpriseSupport = &Rev3{} + +func (r *Rev3) Revision() int64 { + return 201402211147 +} + +func (r *Rev3) Up(op Operation) error { + _, err := op.AddColumn("settings", "github_domain VARCHAR(255)") + if err != nil { + return err + } + _, err = op.AddColumn("settings", "github_apiurl VARCHAR(255)") + + op.Exec("update settings set github_domain=?", "github.com") + op.Exec("update settings set github_apiurl=?", "https://api.github.com") + return err +} + +func (r *Rev3) Down(op Operation) error { + _, err := op.DropColumns("settings", []string{"github_domain", "github_apiurl"}) + return err +} diff --git a/pkg/database/migrate/all.go b/pkg/database/migrate/all.go new file mode 100644 index 000000000..ec5facdca --- /dev/null +++ b/pkg/database/migrate/all.go @@ -0,0 +1,12 @@ +package migrate + +func (m *Migration) All() *Migration { + + // List all migrations here + m.Add(RenamePrivelegedToPrivileged) + m.Add(GitHubEnterpriseSupport) + + // m.Add(...) + // ... + return m +} diff --git a/pkg/database/migrate/migrate.go b/pkg/database/migrate/migrate.go new file mode 100644 index 000000000..63cef3914 --- /dev/null +++ b/pkg/database/migrate/migrate.go @@ -0,0 +1,215 @@ +// Usage +// migrate.To(2) +// .Add(Version_1) +// .Add(Version_2) +// .Add(Version_3) +// .Exec(db) +// +// migrate.ToLatest() +// .Add(Version_1) +// .Add(Version_2) +// .Add(Version_3) +// .SetDialect(migrate.MySQL) +// .Exec(db) +// +// migrate.ToLatest() +// .Add(Version_1) +// .Add(Version_2) +// .Add(Version_3) +// .Backup(path) +// .Exec() + +package migrate + +import ( + "database/sql" + "log" +) + +const migrationTableStmt = ` +CREATE TABLE IF NOT EXISTS migration ( + revision NUMBER PRIMARY KEY +) +` + +const migrationSelectStmt = ` +SELECT revision FROM migration +WHERE revision = ? +` + +const migrationSelectMaxStmt = ` +SELECT max(revision) FROM migration +` + +const insertRevisionStmt = ` +INSERT INTO migration (revision) VALUES (?) +` + +const deleteRevisionStmt = ` +DELETE FROM migration where revision = ? +` + +// Operation interface covers basic migration operations. +// Implementation details is specific for each database, +// see migrate/sqlite.go for implementation reference. +type Operation interface { + CreateTable(tableName string, args []string) (sql.Result, error) + + RenameTable(tableName, newName string) (sql.Result, error) + + DropTable(tableName string) (sql.Result, error) + + AddColumn(tableName, columnSpec string) (sql.Result, error) + + DropColumns(tableName string, columnsToDrop []string) (sql.Result, error) + + RenameColumns(tableName string, columnChanges map[string]string) (sql.Result, error) + + Exec(query string, args ...interface{}) (sql.Result, error) + + Query(query string, args ...interface{}) (*sql.Rows, error) + + QueryRow(query string, args ...interface{}) *sql.Row +} + +type Revision interface { + Up(op Operation) error + Down(op Operation) error + Revision() int64 +} + +type MigrationDriver struct { + Tx *sql.Tx +} + +type Migration struct { + db *sql.DB + revs []Revision +} + +var Driver func(tx *sql.Tx) Operation + +func New(db *sql.DB) *Migration { + return &Migration{db: db} +} + +// Add the Revision to the list of migrations. +func (m *Migration) Add(rev ...Revision) *Migration { + m.revs = append(m.revs, rev...) + return m +} + +// Execute the full list of migrations. +func (m *Migration) Migrate() error { + var target int64 + if len(m.revs) > 0 { + // get the last revision number in + // the list. This is what we'll + // migrate toward. + target = m.revs[len(m.revs)-1].Revision() + } + return m.MigrateTo(target) +} + +// Execute all database migration until +// you are at the specified revision number. +// If the revision number is less than the +// current revision, then we will downgrade. +func (m *Migration) MigrateTo(target int64) error { + + // make sure the migration table is created. + if _, err := m.db.Exec(migrationTableStmt); err != nil { + return err + } + + // get the current revision + var current int64 + m.db.QueryRow(migrationSelectMaxStmt).Scan(¤t) + + // already up to date + if current == target { + log.Println("Database already up-to-date.") + return nil + } + + // should we downgrade? + if target < current { + return m.down(target, current) + } + + // else upgrade + return m.up(target, current) +} + +func (m *Migration) up(target, current int64) error { + // create the database transaction + tx, err := m.db.Begin() + if err != nil { + return err + } + + op := Driver(tx) + + // loop through and execute revisions + for _, rev := range m.revs { + if rev.Revision() > current && rev.Revision() <= target { + current = rev.Revision() + // execute the revision Upgrade. + if err := rev.Up(op); err != nil { + log.Printf("Failed to upgrade to Revision Number %v\n", current) + log.Println(err) + return tx.Rollback() + } + // update the revision number in the database + if _, err := tx.Exec(insertRevisionStmt, current); err != nil { + log.Printf("Failed to register Revision Number %v\n", current) + log.Println(err) + return tx.Rollback() + } + + log.Printf("Successfully upgraded to Revision %v\n", current) + } + } + + return tx.Commit() +} + +func (m *Migration) down(target, current int64) error { + // create the database transaction + tx, err := m.db.Begin() + if err != nil { + return err + } + + op := Driver(tx) + + // reverse the list of revisions + revs := []Revision{} + for _, rev := range m.revs { + revs = append([]Revision{rev}, revs...) + } + + // loop through the (reversed) list of + // revisions and execute. + for _, rev := range revs { + if rev.Revision() > target { + current = rev.Revision() + // execute the revision Upgrade. + if err := rev.Down(op); err != nil { + log.Printf("Failed to downgrade from Revision Number %v\n", current) + log.Println(err) + return tx.Rollback() + } + // update the revision number in the database + if _, err := tx.Exec(deleteRevisionStmt, current); err != nil { + log.Printf("Failed to unregistser Revision Number %v\n", current) + log.Println(err) + return tx.Rollback() + } + + log.Printf("Successfully downgraded from Revision %v\n", current) + } + } + + return tx.Commit() +} diff --git a/pkg/database/migrate/migration b/pkg/database/migrate/migration new file mode 100755 index 000000000..3e3a2ff63 --- /dev/null +++ b/pkg/database/migrate/migration @@ -0,0 +1,30 @@ +#!/usr/bin/env bash + +REV=$(date -u +%Y%m%d%H%M%S) +filename=$1 + +TAB="$(printf '\t')" + +titleize() { + echo "$1" | sed -r -e "s/-|_/ /g" -e 's/\b(.)/\U\1/g' -e 's/ //g' +} + +cat > ${REV}_$filename.go << EOF +package migrate + +type rev${REV} struct{} + +var $(titleize $filename) = &rev${REV}{} + +func (r *rev$REV) Revision() int64 { +${TAB}return $REV +} + +func (r *rev$REV) Up(op Operation) error { +${TAB}// Migration steps here +} + +func (r *rev$REV) Down(op Operation) error { +${TAB}// Revert migration steps here +} +EOF diff --git a/pkg/database/migrate/sqlite.go b/pkg/database/migrate/sqlite.go new file mode 100644 index 000000000..2cec5a026 --- /dev/null +++ b/pkg/database/migrate/sqlite.go @@ -0,0 +1,283 @@ +package migrate + +import ( + "database/sql" + "fmt" + "strings" + + "github.com/dchest/uniuri" + _ "github.com/mattn/go-sqlite3" +) + +type SQLiteDriver MigrationDriver + +func SQLite(tx *sql.Tx) Operation { + return &SQLiteDriver{Tx: tx} +} + +func (s *SQLiteDriver) Exec(query string, args ...interface{}) (sql.Result, error) { + return s.Tx.Exec(query, args...) +} + +func (s *SQLiteDriver) Query(query string, args ...interface{}) (*sql.Rows, error) { + return s.Tx.Query(query, args...) +} + +func (s *SQLiteDriver) QueryRow(query string, args ...interface{}) *sql.Row { + return s.Tx.QueryRow(query, args...) +} + +func (s *SQLiteDriver) CreateTable(tableName string, args []string) (sql.Result, error) { + return s.Tx.Exec(fmt.Sprintf("CREATE TABLE %s (%s);", tableName, strings.Join(args, ", "))) +} + +func (s *SQLiteDriver) RenameTable(tableName, newName string) (sql.Result, error) { + return s.Tx.Exec(fmt.Sprintf("ALTER TABLE %s RENAME TO %s;", tableName, newName)) +} + +func (s *SQLiteDriver) DropTable(tableName string) (sql.Result, error) { + return s.Tx.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s;", tableName)) +} + +func (s *SQLiteDriver) AddColumn(tableName, columnSpec string) (sql.Result, error) { + return s.Tx.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s;", tableName, columnSpec)) +} + +func (s *SQLiteDriver) DropColumns(tableName string, columnsToDrop []string) (sql.Result, error) { + var err error + var result sql.Result + + if len(columnsToDrop) == 0 { + return nil, fmt.Errorf("No columns to drop.") + } + + tableSQL, err := s.getDDLFromTable(tableName) + if err != nil { + return nil, err + } + + columns, err := fetchColumns(tableSQL) + if err != nil { + return nil, err + } + + columnNames := selectName(columns) + + var preparedColumns []string + for k, column := range columnNames { + listed := false + for _, dropped := range columnsToDrop { + if column == dropped { + listed = true + break + } + } + if !listed { + preparedColumns = append(preparedColumns, columns[k]) + } + } + + if len(preparedColumns) == 0 { + return nil, fmt.Errorf("No columns match, drops nothing.") + } + + // fetch indices for this table + oldSQLIndices, err := s.getDDLFromIndex(tableName) + if err != nil { + return nil, err + } + + var oldIdxColumns [][]string + for _, idx := range oldSQLIndices { + idxCols, err := fetchColumns(idx) + if err != nil { + return nil, err + } + oldIdxColumns = append(oldIdxColumns, idxCols) + } + + var indices []string + for k, idx := range oldSQLIndices { + listed := false + OIdxLoop: + for _, oidx := range oldIdxColumns[k] { + for _, cols := range columnsToDrop { + if oidx == cols { + listed = true + break OIdxLoop + } + } + } + if !listed { + indices = append(indices, idx) + } + } + + // Rename old table, here's our proxy + proxyName := fmt.Sprintf("%s_%s", tableName, uniuri.NewLen(16)) + if result, err := s.RenameTable(tableName, proxyName); err != nil { + return result, err + } + + // Recreate table with dropped columns omitted + if result, err = s.CreateTable(tableName, preparedColumns); err != nil { + return result, err + } + + // Move data from old table + if result, err = s.Tx.Exec(fmt.Sprintf("INSERT INTO %s SELECT %s FROM %s;", tableName, + strings.Join(selectName(preparedColumns), ", "), proxyName)); err != nil { + return result, err + } + + // Clean up proxy table + if result, err = s.DropTable(proxyName); err != nil { + return result, err + } + + // Recreate Indices + for _, idx := range indices { + if result, err = s.Tx.Exec(idx); err != nil { + return result, err + } + } + return result, err +} + +func (s *SQLiteDriver) RenameColumns(tableName string, columnChanges map[string]string) (sql.Result, error) { + var err error + var result sql.Result + + tableSQL, err := s.getDDLFromTable(tableName) + if err != nil { + return nil, err + } + + columns, err := fetchColumns(tableSQL) + if err != nil { + return nil, err + } + + // We need a list of columns name to migrate data to the new table + var oldColumnsName = selectName(columns) + + // newColumns will be used to create the new table + var newColumns []string + + for k, column := range oldColumnsName { + added := false + for Old, New := range columnChanges { + if column == Old { + columnToAdd := strings.Replace(columns[k], Old, New, 1) + newColumns = append(newColumns, columnToAdd) + added = true + break + } + } + if !added { + newColumns = append(newColumns, columns[k]) + } + } + + // fetch indices for this table + oldSQLIndices, err := s.getDDLFromIndex(tableName) + if err != nil { + return nil, err + } + + var idxColumns [][]string + for _, idx := range oldSQLIndices { + idxCols, err := fetchColumns(idx) + if err != nil { + return nil, err + } + idxColumns = append(idxColumns, idxCols) + } + + var indices []string + for k, idx := range oldSQLIndices { + added := false + IdcLoop: + for _, oldIdx := range idxColumns[k] { + for Old, New := range columnChanges { + if oldIdx == Old { + indx := strings.Replace(idx, Old, New, 2) + indices = append(indices, indx) + added = true + break IdcLoop + } + } + } + if !added { + indices = append(indices, idx) + } + } + + // Rename current table + proxyName := fmt.Sprintf("%s_%s", tableName, uniuri.NewLen(16)) + if result, err := s.RenameTable(tableName, proxyName); err != nil { + return result, err + } + + // Create new table with the new columns + if result, err = s.CreateTable(tableName, newColumns); err != nil { + return result, err + } + + // Migrate data + if result, err = s.Tx.Exec(fmt.Sprintf("INSERT INTO %s SELECT %s FROM %s", tableName, + strings.Join(oldColumnsName, ", "), proxyName)); err != nil { + return result, err + } + + // Clean up proxy table + if result, err = s.DropTable(proxyName); err != nil { + return result, err + } + + for _, idx := range indices { + if result, err = s.Tx.Exec(idx); err != nil { + return result, err + } + } + return result, err +} + +func (s *SQLiteDriver) getDDLFromTable(tableName string) (string, error) { + var sql string + query := `SELECT sql FROM sqlite_master WHERE type='table' and name=?;` + err := s.Tx.QueryRow(query, tableName).Scan(&sql) + if err != nil { + return "", err + } + return sql, nil +} + +func (s *SQLiteDriver) getDDLFromIndex(tableName string) ([]string, error) { + var sqls []string + + query := `SELECT sql FROM sqlite_master WHERE type='index' and tbl_name=?;` + rows, err := s.Tx.Query(query, tableName) + if err != nil { + return sqls, err + } + + for rows.Next() { + var sql string + if err := rows.Scan(&sql); err != nil { + // This error came from autoindex, since its sql value is null, + // we want to continue. + if strings.Contains(err.Error(), "Scan pair: -> *string") { + continue + } + return sqls, err + } + sqls = append(sqls, sql) + } + + if err := rows.Err(); err != nil { + return sqls, err + } + + return sqls, nil +} diff --git a/pkg/database/migrate/sqlite_test.go b/pkg/database/migrate/sqlite_test.go new file mode 100644 index 000000000..c26d5b9bf --- /dev/null +++ b/pkg/database/migrate/sqlite_test.go @@ -0,0 +1,520 @@ +package migrate + +import ( + "database/sql" + "os" + "strings" + "testing" + + "github.com/russross/meddler" +) + +type Sample struct { + ID int64 `meddler:"id,pk"` + Imel string `meddler:"imel"` + Name string `meddler:"name"` +} + +type RenameSample struct { + ID int64 `meddler:"id,pk"` + Email string `meddler:"email"` + Name string `meddler:"name"` +} + +type AddColumnSample struct { + ID int64 `meddler:"id,pk"` + Imel string `meddler:"imel"` + Name string `meddler:"name"` + Url string `meddler:"url"` + Num int64 `meddler:"num"` +} + +// ---------- revision 1 + +type revision1 struct{} + +func (r *revision1) Up(op Operation) error { + _, err := op.CreateTable("samples", []string{ + "id INTEGER PRIMARY KEY AUTOINCREMENT", + "imel VARCHAR(255) UNIQUE", + "name VARCHAR(255)", + }) + return err +} + +func (r *revision1) Down(op Operation) error { + _, err := op.DropTable("samples") + return err +} + +func (r *revision1) Revision() int64 { + return 1 +} + +// ---------- end of revision 1 + +// ---------- revision 2 + +type revision2 struct{} + +func (r *revision2) Up(op Operation) error { + _, err := op.RenameTable("samples", "examples") + return err +} + +func (r *revision2) Down(op Operation) error { + _, err := op.RenameTable("examples", "samples") + return err +} + +func (r *revision2) Revision() int64 { + return 2 +} + +// ---------- end of revision 2 + +// ---------- revision 3 + +type revision3 struct{} + +func (r *revision3) Up(op Operation) error { + if _, err := op.AddColumn("samples", "url VARCHAR(255)"); err != nil { + return err + } + _, err := op.AddColumn("samples", "num INTEGER") + return err +} + +func (r *revision3) Down(op Operation) error { + _, err := op.DropColumns("samples", []string{"num", "url"}) + return err +} + +func (r *revision3) Revision() int64 { + return 3 +} + +// ---------- end of revision 3 + +// ---------- revision 4 + +type revision4 struct{} + +func (r *revision4) Up(op Operation) error { + _, err := op.RenameColumns("samples", map[string]string{ + "imel": "email", + }) + return err +} + +func (r *revision4) Down(op Operation) error { + _, err := op.RenameColumns("samples", map[string]string{ + "email": "imel", + }) + return err +} + +func (r *revision4) Revision() int64 { + return 4 +} + +// ---------- end of revision 4 + +// ---------- revision 5 + +type revision5 struct{} + +func (r *revision5) Up(op Operation) error { + _, err := op.Exec(`CREATE INDEX samples_url_name_ix ON samples (url, name)`) + return err +} + +func (r *revision5) Down(op Operation) error { + _, err := op.Exec(`DROP INDEX samples_url_name_ix`) + return err +} + +func (r *revision5) Revision() int64 { + return 5 +} + +// ---------- end of revision 5 + +// ---------- revision 6 +type revision6 struct{} + +func (r *revision6) Up(op Operation) error { + _, err := op.RenameColumns("samples", map[string]string{ + "url": "host", + }) + return err +} + +func (r *revision6) Down(op Operation) error { + _, err := op.RenameColumns("samples", map[string]string{ + "host": "url", + }) + return err +} + +func (r *revision6) Revision() int64 { + return 6 +} + +// ---------- end of revision 6 + +// ---------- revision 7 +type revision7 struct{} + +func (r *revision7) Up(op Operation) error { + _, err := op.DropColumns("samples", []string{"host", "num"}) + return err +} + +func (r *revision7) Down(op Operation) error { + if _, err := op.AddColumn("samples", "host VARCHAR(255)"); err != nil { + return err + } + _, err := op.AddColumn("samples", "num INSTEGER") + return err +} + +func (r *revision7) Revision() int64 { + return 7 +} + +// ---------- end of revision 7 + +// ---------- revision 8 +type revision8 struct{} + +func (r *revision8) Up(op Operation) error { + if _, err := op.AddColumn("samples", "repo_id INTEGER"); err != nil { + return err + } + _, err := op.AddColumn("samples", "repo VARCHAR(255)") + return err +} + +func (r *revision8) Down(op Operation) error { + _, err := op.DropColumns("samples", []string{"repo", "repo_id"}) + return err +} + +func (r *revision8) Revision() int64 { + return 8 +} + +// ---------- end of revision 8 + +// ---------- revision 9 +type revision9 struct{} + +func (r *revision9) Up(op Operation) error { + _, err := op.RenameColumns("samples", map[string]string{ + "repo": "repository", + }) + return err +} + +func (r *revision9) Down(op Operation) error { + _, err := op.RenameColumns("samples", map[string]string{ + "repository": "repo", + }) + return err +} + +func (r *revision9) Revision() int64 { + return 9 +} + +// ---------- end of revision 9 + +var db *sql.DB + +var testSchema = ` +CREATE TABLE samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + imel VARCHAR(255) UNIQUE, + name VARCHAR(255) +); +` + +var dataDump = []string{ + `INSERT INTO samples (imel, name) VALUES ('test@example.com', 'Test Tester');`, + `INSERT INTO samples (imel, name) VALUES ('foo@bar.com', 'Foo Bar');`, + `INSERT INTO samples (imel, name) VALUES ('crash@bandicoot.io', 'Crash Bandicoot');`, +} + +func TestMigrateCreateTable(t *testing.T) { + defer tearDown() + if err := setUp(); err != nil { + t.Fatalf("Error preparing database: %q", err) + } + + Driver = SQLite + + mgr := New(db) + if err := mgr.Add(&revision1{}).Migrate(); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + sample := Sample{ + ID: 1, + Imel: "test@example.com", + Name: "Test Tester", + } + if err := meddler.Save(db, "samples", &sample); err != nil { + t.Errorf("Can not save data: %q", err) + } +} + +func TestMigrateRenameTable(t *testing.T) { + defer tearDown() + if err := setUp(); err != nil { + t.Fatalf("Error preparing database: %q", err) + } + + Driver = SQLite + + mgr := New(db) + if err := mgr.Add(&revision1{}).Migrate(); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + loadFixture(t) + + if err := mgr.Add(&revision2{}).Migrate(); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + sample := Sample{} + if err := meddler.QueryRow(db, &sample, `SELECT * FROM examples WHERE id = ?`, 2); err != nil { + t.Errorf("Can not fetch data: %q", err) + } + + if sample.Imel != "foo@bar.com" { + t.Errorf("Column doesn't match. Expect: %s, got: %s", "foo@bar.com", sample.Imel) + } +} + +type TableInfo struct { + CID int64 `meddler:"cid,pk"` + Name string `meddler:"name"` + Type string `meddler:"type"` + Notnull bool `meddler:"notnull"` + DfltValue interface{} `meddler:"dflt_value"` + PK bool `meddler:"pk"` +} + +func TestMigrateAddRemoveColumns(t *testing.T) { + defer tearDown() + if err := setUp(); err != nil { + t.Fatalf("Error preparing database: %q", err) + } + + Driver = SQLite + + mgr := New(db) + if err := mgr.Add(&revision1{}, &revision3{}).Migrate(); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + var columns []*TableInfo + if err := meddler.QueryAll(db, &columns, `PRAGMA table_info(samples);`); err != nil { + t.Errorf("Can not access table info: %q", err) + } + + if len(columns) < 5 { + t.Errorf("Expect length columns: %d\nGot: %d", 5, len(columns)) + } + + var row = AddColumnSample{ + ID: 33, + Name: "Foo", + Imel: "foo@bar.com", + Url: "http://example.com", + Num: 42, + } + if err := meddler.Save(db, "samples", &row); err != nil { + t.Errorf("Can not save into database: %q", err) + } + + if err := mgr.MigrateTo(1); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + var another_columns []*TableInfo + if err := meddler.QueryAll(db, &another_columns, `PRAGMA table_info(samples);`); err != nil { + t.Errorf("Can not access table info: %q", err) + } + + if len(another_columns) != 3 { + t.Errorf("Expect length columns = %d, got: %d", 3, len(columns)) + } +} + +func TestRenameColumn(t *testing.T) { + defer tearDown() + if err := setUp(); err != nil { + t.Fatalf("Error preparing database: %q", err) + } + + Driver = SQLite + + mgr := New(db) + if err := mgr.Add(&revision1{}, &revision4{}).MigrateTo(1); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + loadFixture(t) + + if err := mgr.MigrateTo(4); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + row := RenameSample{} + if err := meddler.QueryRow(db, &row, `SELECT * FROM samples WHERE id = 3;`); err != nil { + t.Errorf("Can not query database: %q", err) + } + + if row.Email != "crash@bandicoot.io" { + t.Errorf("Expect %s, got %s", "crash@bandicoot.io", row.Email) + } +} + +func TestMigrateExistingTable(t *testing.T) { + defer tearDown() + if err := setUp(); err != nil { + t.Fatalf("Error preparing database: %q", err) + } + + Driver = SQLite + + if _, err := db.Exec(testSchema); err != nil { + t.Errorf("Can not create database: %q", err) + } + + loadFixture(t) + + mgr := New(db) + if err := mgr.Add(&revision4{}).Migrate(); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + var rows []*RenameSample + if err := meddler.QueryAll(db, &rows, `SELECT * from samples;`); err != nil { + t.Errorf("Can not query database: %q", err) + } + + if len(rows) != 3 { + t.Errorf("Expect rows length = %d, got %d", 3, len(rows)) + } + + if rows[1].Email != "foo@bar.com" { + t.Errorf("Expect email = %s, got %s", "foo@bar.com", rows[1].Email) + } +} + +type sqliteMaster struct { + Sql interface{} `meddler:"sql"` +} + +func TestIndexOperations(t *testing.T) { + defer tearDown() + if err := setUp(); err != nil { + t.Fatalf("Error preparing database: %q", err) + } + + Driver = SQLite + + mgr := New(db) + + // Migrate, create index + if err := mgr.Add(&revision1{}, &revision3{}, &revision5{}).Migrate(); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + var esquel []*sqliteMaster + // Query sqlite_master, check if index is exists. + query := `SELECT sql FROM sqlite_master WHERE type='index' and tbl_name='samples'` + if err := meddler.QueryAll(db, &esquel, query); err != nil { + t.Errorf("Can not find index: %q", err) + } + + indexStatement := `CREATE INDEX samples_url_name_ix ON samples (url, name)` + if string(esquel[1].Sql.([]byte)) != indexStatement { + t.Errorf("Can not find index") + } + + // Migrate, rename indexed columns + if err := mgr.Add(&revision6{}).Migrate(); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + var esquel1 []*sqliteMaster + if err := meddler.QueryAll(db, &esquel1, query); err != nil { + t.Errorf("Can not find index: %q", err) + } + + indexStatement = `CREATE INDEX samples_host_name_ix ON samples (host, name)` + if string(esquel1[1].Sql.([]byte)) != indexStatement { + t.Errorf("Can not find index, got: %s", esquel[0]) + } + + if err := mgr.Add(&revision7{}).Migrate(); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + var esquel2 []*sqliteMaster + if err := meddler.QueryAll(db, &esquel2, query); err != nil { + t.Errorf("Can not find index: %q", err) + } + + if len(esquel2) != 1 { + t.Errorf("Expect row length equal to %d, got %d", 1, len(esquel2)) + } +} + +func TestColumnRedundancy(t *testing.T) { + defer tearDown() + if err := setUp(); err != nil { + t.Fatalf("Error preparing database: %q", err) + } + + Driver = SQLite + + migr := New(db) + if err := migr.Add(&revision1{}, &revision8{}, &revision9{}).Migrate(); err != nil { + t.Errorf("Can not migrate: %q", err) + } + + var tableSql string + query := `SELECT sql FROM sqlite_master where type='table' and name='samples'` + if err := db.QueryRow(query).Scan(&tableSql); err != nil { + t.Errorf("Can not query sqlite_master: %q", err) + } + + if !strings.Contains(tableSql, "repository ") { + t.Errorf("Expect column with name repository") + } +} + +func setUp() error { + var err error + db, err = sql.Open("sqlite3", "migration_tests.sqlite") + return err +} + +func tearDown() { + db.Close() + os.Remove("migration_tests.sqlite") +} + +func loadFixture(t *testing.T) { + for _, sql := range dataDump { + if _, err := db.Exec(sql); err != nil { + t.Errorf("Can not insert into database: %q", err) + } + } +} diff --git a/pkg/database/migrate/util.go b/pkg/database/migrate/util.go new file mode 100644 index 000000000..d2001a071 --- /dev/null +++ b/pkg/database/migrate/util.go @@ -0,0 +1,32 @@ +package migrate + +import ( + "fmt" + "strings" +) + +func fetchColumns(sql string) ([]string, error) { + if !strings.HasPrefix(sql, "CREATE ") { + return []string{}, fmt.Errorf("Sql input is not a DDL statement.") + } + + parenIdx := strings.Index(sql, "(") + return strings.Split(sql[parenIdx+1:strings.LastIndex(sql, ")")], ","), nil +} + +func selectName(columns []string) []string { + var results []string + for _, column := range columns { + col := strings.SplitN(strings.Trim(column, " \n\t"), " ", 2) + results = append(results, col[0]) + } + return results +} + +func setForUpdate(left []string, right []string) string { + var results []string + for k, str := range left { + results = append(results, fmt.Sprintf("%s = %s", str, right[k])) + } + return strings.Join(results, ", ") +} diff --git a/pkg/database/repos.go b/pkg/database/repos.go index e4407c028..0d9e2f15e 100644 --- a/pkg/database/repos.go +++ b/pkg/database/repos.go @@ -13,7 +13,7 @@ const repoTable = "repos" // SQL Queries to retrieve a list of all repos belonging to a User. const repoStmt = ` SELECT id, slug, host, owner, name, private, disabled, disabled_pr, scm, url, username, password, -public_key, private_key, params, timeout, priveleged, created, updated, user_id, team_id +public_key, private_key, params, timeout, privileged, created, updated, user_id, team_id FROM repos WHERE user_id = ? AND team_id = 0 ORDER BY slug ASC @@ -22,7 +22,7 @@ ORDER BY slug ASC // SQL Queries to retrieve a list of all repos belonging to a Team. const repoTeamStmt = ` SELECT id, slug, host, owner, name, private, disabled, disabled_pr, scm, url, username, password, -public_key, private_key, params, timeout, priveleged, created, updated, user_id, team_id +public_key, private_key, params, timeout, privileged, created, updated, user_id, team_id FROM repos WHERE team_id = ? ORDER BY slug ASC @@ -31,7 +31,7 @@ ORDER BY slug ASC // SQL Queries to retrieve a repo by id. const repoFindStmt = ` SELECT id, slug, host, owner, name, private, disabled, disabled_pr, scm, url, username, password, -public_key, private_key, params, timeout, priveleged, created, updated, user_id, team_id +public_key, private_key, params, timeout, privileged, created, updated, user_id, team_id FROM repos WHERE id = ? ` @@ -39,7 +39,7 @@ WHERE id = ? // SQL Queries to retrieve a repo by name. const repoFindSlugStmt = ` SELECT id, slug, host, owner, name, private, disabled, disabled_pr, scm, url, username, password, -public_key, private_key, params, timeout, priveleged, created, updated, user_id, team_id +public_key, private_key, params, timeout, privileged, created, updated, user_id, team_id FROM repos WHERE slug = ? ` diff --git a/pkg/database/schema/sample.sql b/pkg/database/schema/sample.sql index 6b3f6f297..c19dbe4b9 100644 --- a/pkg/database/schema/sample.sql +++ b/pkg/database/schema/sample.sql @@ -49,7 +49,7 @@ insert into builds values (9, 3, 'node_0.80', 'Success', '2013-09-16 00:00:00',' -- insert default, dummy settings -insert into settings values (1,'','','','','','','','','','localhost:8080','http'); +insert into settings values (1,'','','github.com','https://api.github.com','','','','','','','','localhost:8080','http',0); -- add public & private keys to all repositories @@ -123,4 +123,4 @@ Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 10.849 sec Results : -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0'; \ No newline at end of file +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0'; diff --git a/pkg/database/schema/schema.go b/pkg/database/schema/schema.go index 993db07e2..ec93774a8 100644 --- a/pkg/database/schema/schema.go +++ b/pkg/database/schema/schema.go @@ -127,6 +127,7 @@ CREATE TABLE settings ( ,smtp_password VARCHAR(1024) ,hostname VARCHAR(1024) ,scheme VARCHAR(5) + ,open_invitations BOOLEAN ); ` @@ -194,5 +195,9 @@ func Load(db *sql.DB) error { db.Exec(buildCommitIndex) db.Exec(buildSlugIndex) + // migrations for backward compatibility + db.Exec("ALTER TABLE settings ADD COLUMN open_invitations BOOLEAN") + db.Exec("UPDATE settings SET open_invitations=0 WHERE open_invitations IS NULL") + return nil } diff --git a/pkg/database/schema/schema.sql b/pkg/database/schema/schema.sql index 950458508..cae432d07 100644 --- a/pkg/database/schema/schema.sql +++ b/pkg/database/schema/schema.sql @@ -51,7 +51,7 @@ CREATE TABLE repos ( ,private BOOLEAN ,disabled BOOLEAN ,disabled_pr BOOLEAN - ,priveleged BOOLEAN + ,privileged BOOLEAN ,timeout INTEGER ,scm VARCHAR(25) @@ -103,6 +103,8 @@ CREATE TABLE settings ( id INTEGER PRIMARY KEY ,github_key VARCHAR(255) ,github_secret VARCHAR(255) + ,github_domain VARCHAR(255) + ,github_apiurl VARCHAR(255) ,bitbucket_key VARCHAR(255) ,bitbucket_secret VARCHAR(255) ,smtp_server VARCHAR(1024) @@ -112,6 +114,7 @@ CREATE TABLE settings ( ,smtp_password VARCHAR(1024) ,hostname VARCHAR(1024) ,scheme VARCHAR(5) + ,open_invitations BOOLEAN ); CREATE UNIQUE INDEX member_uix ON members (team_id, user_id); diff --git a/pkg/database/settings.go b/pkg/database/settings.go index 0e210c991..883b87d3d 100644 --- a/pkg/database/settings.go +++ b/pkg/database/settings.go @@ -10,8 +10,8 @@ const settingsTable = "settings" // SQL Queries to retrieve the system settings const settingsStmt = ` -SELECT id, github_key, github_secret, bitbucket_key, bitbucket_secret, -smtp_server, smtp_port, smtp_address, smtp_username, smtp_password, hostname, scheme +SELECT id, github_key, github_secret, github_domain, github_apiurl, bitbucket_key, bitbucket_secret, +smtp_server, smtp_port, smtp_address, smtp_username, smtp_password, hostname, scheme, open_invitations FROM settings WHERE id = 1 ` diff --git a/pkg/database/testing/testing.go b/pkg/database/testing/testing.go index 11106e2d3..69fde3787 100644 --- a/pkg/database/testing/testing.go +++ b/pkg/database/testing/testing.go @@ -7,6 +7,7 @@ import ( "github.com/drone/drone/pkg/database" "github.com/drone/drone/pkg/database/encrypt" + "github.com/drone/drone/pkg/database/migrate" . "github.com/drone/drone/pkg/model" _ "github.com/mattn/go-sqlite3" @@ -31,6 +32,7 @@ func init() { // notify meddler that we are working with sqlite meddler.Default = meddler.SQLite + migrate.Driver = migrate.SQLite } func Setup() { @@ -40,6 +42,9 @@ func Setup() { // make sure all the tables and indexes are created database.Set(db) + migration := migrate.New(db) + migration.All().Migrate() + // create dummy user data user1 := User{ Password: "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", diff --git a/pkg/database/testing/users_test.go b/pkg/database/testing/users_test.go index 9742c42bb..988e4aeb5 100644 --- a/pkg/database/testing/users_test.go +++ b/pkg/database/testing/users_test.go @@ -28,6 +28,10 @@ func TestGetUser(t *testing.T) { t.Errorf("Exepected Password %s, got %s", "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", u.Password) } + if u.Token != "123" { + t.Errorf("Exepected Token %s, got %s", "123", u.Token) + } + if u.Name != "Brad Rydzewski" { t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", u.Name) } @@ -60,6 +64,10 @@ func TestGetUserEmail(t *testing.T) { t.Errorf("Exepected Password %s, got %s", "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", u.Password) } + if u.Token != "123" { + t.Errorf("Exepected Token %s, got %s", "123", u.Token) + } + if u.Name != "Brad Rydzewski" { t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", u.Name) } @@ -155,6 +163,10 @@ func TestListUsers(t *testing.T) { t.Errorf("Exepected Password %s, got %s", "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", u.Password) } + if u.Token != "123" { + t.Errorf("Exepected Token %s, got %s", "123", u.Token) + } + if u.Name != "Brad Rydzewski" { t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", u.Name) } diff --git a/pkg/database/users.go b/pkg/database/users.go index 602f71d84..3bea97452 100644 --- a/pkg/database/users.go +++ b/pkg/database/users.go @@ -12,21 +12,21 @@ const userTable = "users" // SQL Queries to retrieve a user by their unique database key const userFindIdStmt = ` -SELECT id, email, password, name, gravatar, created, updated, admin, +SELECT id, email, password, token, name, gravatar, created, updated, admin, github_login, github_token, bitbucket_login, bitbucket_token, bitbucket_secret FROM users WHERE id = ? ` // SQL Queries to retrieve a user by their email address const userFindEmailStmt = ` -SELECT id, email, password, name, gravatar, created, updated, admin, +SELECT id, email, password, token, name, gravatar, created, updated, admin, github_login, github_token, bitbucket_login, bitbucket_token, bitbucket_secret FROM users WHERE email = ? ` // SQL Queries to retrieve a list of all users const userStmt = ` -SELECT id, email, password, name, gravatar, created, updated, admin, +SELECT id, email, password, token, name, gravatar, created, updated, admin, github_login, github_token, bitbucket_login, bitbucket_token, bitbucket_secret FROM users ORDER BY name ASC diff --git a/pkg/handler/admin.go b/pkg/handler/admin.go index be3b67a75..d7840c7fb 100644 --- a/pkg/handler/admin.go +++ b/pkg/handler/admin.go @@ -2,7 +2,6 @@ package handler import ( "fmt" - "log" "net/http" "strconv" "time" @@ -33,8 +32,7 @@ func AdminUserAdd(w http.ResponseWriter, r *http.Request, u *User) error { return RenderTemplate(w, "admin_users_add.html", &struct{ User *User }{u}) } -// Invite a user to join the system -func AdminUserInvite(w http.ResponseWriter, r *http.Request, u *User) error { +func UserInvite(w http.ResponseWriter, r *http.Request) error { // generate the password reset token email := r.FormValue("email") token := authcookie.New(email, time.Now().Add(12*time.Hour), secret) @@ -57,15 +55,16 @@ func AdminUserInvite(w http.ResponseWriter, r *http.Request, u *User) error { }{hostname, email, token} // send the email message async - go func() { - if err := mail.SendActivation(email, data); err != nil { - log.Printf("error sending account activation email to %s. %s", email, err) - } - }() + go mail.SendActivation(email, data) return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) } +// Invite a user to join the system +func AdminUserInvite(w http.ResponseWriter, r *http.Request, u *User) error { + return UserInvite(w, r) +} + // Form to edit a user func AdminUserEdit(w http.ResponseWriter, r *http.Request, u *User) error { idstr := r.FormValue("id") @@ -140,7 +139,7 @@ func AdminUserDelete(w http.ResponseWriter, r *http.Request, u *User) error { return nil } -// Display a list of ALL users in the system +// Return an HTML form for the User to update the site settings. func AdminSettings(w http.ResponseWriter, r *http.Request, u *User) error { // get settings from database settings := database.SettingsMust() @@ -153,7 +152,6 @@ func AdminSettings(w http.ResponseWriter, r *http.Request, u *User) error { return RenderTemplate(w, "admin_settings.html", &data) } -// Display a list of ALL users in the system func AdminSettingsUpdate(w http.ResponseWriter, r *http.Request, u *User) error { // get settings from database settings := database.SettingsMust() @@ -169,6 +167,8 @@ func AdminSettingsUpdate(w http.ResponseWriter, r *http.Request, u *User) error // update github settings settings.GitHubKey = r.FormValue("GitHubKey") settings.GitHubSecret = r.FormValue("GitHubSecret") + settings.GitHubDomain = r.FormValue("GitHubDomain") + settings.GitHubApiUrl = r.FormValue("GitHubApiUrl") // update smtp settings settings.SmtpServer = r.FormValue("SmtpServer") @@ -177,6 +177,8 @@ func AdminSettingsUpdate(w http.ResponseWriter, r *http.Request, u *User) error settings.SmtpUsername = r.FormValue("SmtpUsername") settings.SmtpPassword = r.FormValue("SmtpPassword") + settings.OpenInvitations = (r.FormValue("OpenInvitations") == "on") + // persist changes if err := database.SaveSettings(settings); err != nil { return RenderError(w, err, http.StatusBadRequest) @@ -243,6 +245,8 @@ func InstallPost(w http.ResponseWriter, r *http.Request) error { settings := Settings{} settings.Domain = r.FormValue("Domain") settings.Scheme = r.FormValue("Scheme") + settings.GitHubApiUrl = "https://api.github.com"; + settings.GitHubDomain = "github.com"; database.SaveSettings(&settings) // add the user to the session object diff --git a/pkg/handler/app.go b/pkg/handler/app.go index 86cd1121d..9d720113c 100644 --- a/pkg/handler/app.go +++ b/pkg/handler/app.go @@ -47,7 +47,13 @@ func Index(w http.ResponseWriter, r *http.Request) error { // Return an HTML form for the User to login. func Login(w http.ResponseWriter, r *http.Request) error { - return RenderTemplate(w, "login.html", nil) + var settings, _ = database.GetSettings() + + data := struct { + Settings *Settings + }{settings} + + return RenderTemplate(w, "login.html", &data) } // Terminate the User session. @@ -70,6 +76,18 @@ func Reset(w http.ResponseWriter, r *http.Request) error { return RenderTemplate(w, "reset.html", &struct{ Error string }{""}) } +// Return an HTML form for the User to signup. +func SignUp(w http.ResponseWriter, r *http.Request) error { + var settings, _ = database.GetSettings() + + if settings == nil || !settings.OpenInvitations { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return nil + } + + return RenderTemplate(w, "signup.html", nil) +} + // Return an HTML form to register for a new account. This // page must be visited from a Signup email that contains // a hash to verify the Email address is correct. @@ -138,15 +156,40 @@ func ResetPost(w http.ResponseWriter, r *http.Request) error { } // add the user to the session object - //session, _ := store.Get(r, "_sess") - //session.Values["username"] = user.Email - //session.Save(r, w) SetCookie(w, r, "_sess", user.Email) http.Redirect(w, r, "/dashboard", http.StatusSeeOther) return nil } +func SignUpPost(w http.ResponseWriter, r *http.Request) error { + // if self-registration is disabled we should display an + // error message to the user. + if !database.SettingsMust().OpenInvitations { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return nil + } + + // generate the password reset token + email := r.FormValue("email") + token := authcookie.New(email, time.Now().Add(12*time.Hour), secret) + + // get the hostname from the database for use in the email + hostname := database.SettingsMust().URL().String() + + // data used to generate the email template + data := struct { + Host string + Email string + Token string + }{hostname, email, token} + + // send the email message async + go mail.SendActivation(email, data) + + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + func RegisterPost(w http.ResponseWriter, r *http.Request) error { // verify the token and extract the username token := r.FormValue("token") @@ -156,9 +199,7 @@ func RegisterPost(w http.ResponseWriter, r *http.Request) error { } // set the email and name - user := User{} - user.SetEmail(email) - user.Name = r.FormValue("name") + user := NewUser(r.FormValue("name"), email) // set the new password password := r.FormValue("password") @@ -172,7 +213,7 @@ func RegisterPost(w http.ResponseWriter, r *http.Request) error { } // save to the database - if err := database.SaveUser(&user); err != nil { + if err := database.SaveUser(user); err != nil { return err } diff --git a/pkg/handler/auth.go b/pkg/handler/auth.go index 091bb494d..7e91787ee 100644 --- a/pkg/handler/auth.go +++ b/pkg/handler/auth.go @@ -48,8 +48,8 @@ func LinkGithub(w http.ResponseWriter, r *http.Request, u *User) error { // github OAuth2 Data var oauth = oauth2.Client{ RedirectURL: settings.URL().String() + "/auth/login/github", - AccessTokenURL: "https://github.com/login/oauth/access_token", - AuthorizationURL: "https://github.com/login/oauth/authorize", + AccessTokenURL: "https://" + settings.GitHubDomain + "/login/oauth/access_token", + AuthorizationURL: "https://" + settings.GitHubDomain + "/login/oauth/authorize", ClientId: settings.GitHubKey, ClientSecret: settings.GitHubSecret, } @@ -72,6 +72,7 @@ func LinkGithub(w http.ResponseWriter, r *http.Request, u *User) error { // create the client client := github.New(token.AccessToken) + client.ApiUrl = settings.GitHubApiUrl // get the user information githubUser, err := client.Users.Current() diff --git a/pkg/handler/badges.go b/pkg/handler/badges.go index bfe13e612..20415115d 100644 --- a/pkg/handler/badges.go +++ b/pkg/handler/badges.go @@ -11,7 +11,7 @@ import ( // repository and an optional branch. // TODO this needs to implement basic caching func Badge(w http.ResponseWriter, r *http.Request) error { - branchParam := r.FormValue(":branch") + branchParam := r.FormValue("branch") hostParam := r.FormValue(":host") ownerParam := r.FormValue(":owner") nameParam := r.FormValue(":name") diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go index 2b3d28069..bf9914e4f 100644 --- a/pkg/handler/handler.go +++ b/pkg/handler/handler.go @@ -41,7 +41,7 @@ func (h UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // AdminHandler wraps the default http.HandlerFunc to include // the currently authenticated User in the method signature, // in addition to handling an error as the return value. It also -// verifies the user has Administrative priveleges. +// verifies the user has Administrative privileges. type AdminHandler func(w http.ResponseWriter, r *http.Request, user *User) error func (h AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -51,7 +51,7 @@ func (h AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // User MUST have administrative priveleges in order + // User MUST have administrative privileges in order // to execute the handler. if user.Admin == false { RenderNotFound(w) diff --git a/pkg/handler/hooks.go b/pkg/handler/hooks.go index dbec12705..2cb719681 100644 --- a/pkg/handler/hooks.go +++ b/pkg/handler/hooks.go @@ -100,8 +100,13 @@ func Hook(w http.ResponseWriter, r *http.Request) error { commit.SetAuthor(hook.Commits[0].Author.Email) } + // get the github settings from the database + settings := database.SettingsMust() + // get the drone.yml file from GitHub client := github.New(user.GithubToken) + client.ApiUrl = settings.GitHubApiUrl + content, err := client.Contents.FindRef(repo.Owner, repo.Name, ".drone.yml", commit.Hash) if err != nil { msg := "No .drone.yml was found in this repository. You need to add one.\n" @@ -122,7 +127,7 @@ func Hook(w http.ResponseWriter, r *http.Request) error { } // parse the build script - buildscript, err := script.ParseBuild(raw) + buildscript, err := script.ParseBuild(raw, repo.Params) if err != nil { msg := "Could not parse your .drone.yml file. It needs to be a valid drone yaml file.\n\n" + err.Error() + "\n" if err := saveFailedBuild(commit, msg); err != nil { @@ -216,8 +221,13 @@ func PullRequestHook(w http.ResponseWriter, r *http.Request) { commit.Message = hook.PullRequest.Title // label := p.PullRequest.Head.Labe + // get the github settings from the database + settings := database.SettingsMust() + // get the drone.yml file from GitHub client := github.New(user.GithubToken) + client.ApiUrl = settings.GitHubApiUrl + content, err := client.Contents.FindRef(repo.Owner, repo.Name, ".drone.yml", commit.Hash) // TODO should this really be the hash?? if err != nil { println(err.Error()) @@ -233,7 +243,7 @@ func PullRequestHook(w http.ResponseWriter, r *http.Request) { } // parse the build script - buildscript, err := script.ParseBuild(raw) + buildscript, err := script.ParseBuild(raw, repo.Params) if err != nil { // TODO if the YAML is invalid we should create a commit record // with an ERROR status so that the user knows why a build wasn't diff --git a/pkg/handler/members.go b/pkg/handler/members.go index ff0dea55d..27e4b0af9 100644 --- a/pkg/handler/members.go +++ b/pkg/handler/members.go @@ -173,7 +173,7 @@ func TeamMemberInvite(w http.ResponseWriter, r *http.Request, u *User) error { } // generate a token that is valid for 3 days to join the team - token := authcookie.New(team.Name, time.Now().Add(72*time.Hour), secret) + token := authcookie.New(strconv.Itoa(int(team.ID)), time.Now().Add(72*time.Hour), secret) // hostname from settings hostname := database.SettingsMust().URL().String() @@ -202,14 +202,14 @@ func TeamMemberInvite(w http.ResponseWriter, r *http.Request, u *User) error { func TeamMemberAccept(w http.ResponseWriter, r *http.Request, u *User) error { // get the team name from the token token := r.FormValue("token") - teamName := authcookie.Login(token, secret) - if len(teamName) == 0 { + teamToken := authcookie.Login(token, secret) + teamId, err := strconv.Atoi(teamToken) + if err != nil || teamId == 0 { return ErrInvalidTeamName } // get the team from the database - // TODO it might make more sense to use the ID in case the Slug changes - team, err := database.GetTeamSlug(teamName) + team, err := database.GetTeam(int64(teamId)) if err != nil { return RenderError(w, err, http.StatusNotFound) } @@ -222,6 +222,6 @@ func TeamMemberAccept(w http.ResponseWriter, r *http.Request, u *User) error { } // send the user to the dashboard - http.Redirect(w, r, "/dashboard/team/"+team.Name, http.StatusSeeOther) + http.Redirect(w, r, "/dashboard/team/"+team.Slug, http.StatusSeeOther) return nil } diff --git a/pkg/handler/repos.go b/pkg/handler/repos.go index 99195e104..fe3336c01 100644 --- a/pkg/handler/repos.go +++ b/pkg/handler/repos.go @@ -15,7 +15,7 @@ import ( // Display a Repository dashboard. func RepoDashboard(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error { - branch := r.FormValue(":branch") + branch := r.FormValue("branch") // get a list of all branches branches, err := database.ListBranches(repo.ID) @@ -54,6 +54,7 @@ func RepoDashboard(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) } func RepoAdd(w http.ResponseWriter, r *http.Request, u *User) error { + settings := database.SettingsMust() teams, err := database.ListTeams(u.ID) if err != nil { return err @@ -61,7 +62,8 @@ func RepoAdd(w http.ResponseWriter, r *http.Request, u *User) error { data := struct { User *User Teams []*Team - }{u, teams} + Settings *Settings + }{u, teams, settings} // if the user hasn't linked their GitHub account // render a different template if len(u.GithubToken) == 0 { @@ -82,12 +84,13 @@ func RepoCreateGithub(w http.ResponseWriter, r *http.Request, u *User) error { // create the GitHub client client := github.New(u.GithubToken) + client.ApiUrl = settings.GitHubApiUrl githubRepo, err := client.Repos.Find(owner, name) if err != nil { return err } - repo, err := NewGitHubRepo(owner, name, githubRepo.Private) + repo, err := NewGitHubRepo(settings.GitHubDomain, owner, name, githubRepo.Private) if err != nil { return err } @@ -274,7 +277,7 @@ func RepoDelete(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) err // the user must confirm their password before deleting password := r.FormValue("password") if err := u.ComparePassword(password); err != nil { - return err + return RenderError(w, err, http.StatusBadRequest) } // delete the repo diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go index aa63d444f..2bc83938b 100644 --- a/pkg/mail/mail.go +++ b/pkg/mail/mail.go @@ -122,7 +122,11 @@ func Send(msg *Message) error { // format the raw email message body body := fmt.Sprintf(emailTemplate, msg.Sender, msg.To, msg.Subject, msg.Body) - auth := smtp.PlainAuth("", s.SmtpUsername, s.SmtpPassword, s.SmtpServer) + + var auth smtp.Auth + if len(s.SmtpUsername) > 0 { + auth = smtp.PlainAuth("", s.SmtpUsername, s.SmtpPassword, s.SmtpServer) + } addr := fmt.Sprintf("%s:%s", s.SmtpServer, s.SmtpPort) err = smtp.SendMail(addr, auth, msg.Sender, []string{msg.To}, []byte(body)) diff --git a/pkg/model/repo.go b/pkg/model/repo.go index 5b89ad71c..c420b7976 100644 --- a/pkg/model/repo.go +++ b/pkg/model/repo.go @@ -12,7 +12,6 @@ const ( ) const ( - HostGithub = "github.com" HostBitbucket = "bitbucket.org" HostGoogle = "code.google.com" HostCustom = "custom" @@ -25,8 +24,8 @@ const ( ) const ( - githubRepoPattern = "git://github.com/%s/%s.git" - githubRepoPatternPrivate = "git@github.com:%s/%s.git" + githubRepoPattern = "git://%s/%s/%s.git" + githubRepoPatternPrivate = "git@%s:%s/%s.git" bitbucketRepoPattern = "https://bitbucket.org/%s/%s.git" bitbucketRepoPatternPrivate = "git@bitbucket.org:%s/%s.git" ) @@ -88,9 +87,9 @@ type Repo struct { // before exceeding its timelimit and being killed. Timeout int64 `meddler:"timeout" json:"timeout"` - // Indicates the build should be executed in priveleged + // Indicates the build should be executed in privileged // mode. This could, for example, be used to run Docker in Docker. - Priveleged bool `meddler:"priveleged" json:"priveleged"` + Privileged bool `meddler:"privileged" json:"privileged"` // Foreign keys signify the User that created // the repository and team account linked to @@ -122,15 +121,15 @@ func NewRepo(host, owner, name, scm, url string) (*Repo, error) { } // Creates a new GitHub repository -func NewGitHubRepo(owner, name string, private bool) (*Repo, error) { +func NewGitHubRepo(domain, owner, name string, private bool) (*Repo, error) { var url string switch private { case false: - url = fmt.Sprintf(githubRepoPattern, owner, name) + url = fmt.Sprintf(githubRepoPattern, domain, owner, name) case true: - url = fmt.Sprintf(githubRepoPatternPrivate, owner, name) + url = fmt.Sprintf(githubRepoPatternPrivate, domain, owner, name) } - return NewRepo(HostGithub, owner, name, ScmGit, url) + return NewRepo(domain, owner, name, ScmGit, url) } // Creates a new Bitbucket repository @@ -142,7 +141,7 @@ func NewBitbucketRepo(owner, name string, private bool) (*Repo, error) { case true: url = fmt.Sprintf(bitbucketRepoPatternPrivate, owner, name) } - return NewRepo(HostGithub, owner, name, ScmGit, url) + return NewRepo(HostBitbucket, owner, name, ScmGit, url) } func (r *Repo) DefaultBranch() string { diff --git a/pkg/model/settings.go b/pkg/model/settings.go index ec7f69158..3112d1d4b 100644 --- a/pkg/model/settings.go +++ b/pkg/model/settings.go @@ -17,6 +17,8 @@ type Settings struct { // GitHub Consumer key and secret. GitHubKey string `meddler:"github_key"` GitHubSecret string `meddler:"github_secret"` + GitHubDomain string `meddler:"github_domain"` + GitHubApiUrl string `meddler:"github_apiurl"` // Bitbucket Consumer Key and secret. BitbucketKey string `meddler:"bitbucket_key"` @@ -27,6 +29,8 @@ type Settings struct { // Scheme of the server, eg https Scheme string `meddler:"scheme"` + + OpenInvitations bool `meddler:"open_invitations"` } func (s *Settings) URL() *url.URL { diff --git a/pkg/plugin/deploy/deployment.go b/pkg/plugin/deploy/deployment.go index 71e79c718..0181586c4 100644 --- a/pkg/plugin/deploy/deployment.go +++ b/pkg/plugin/deploy/deployment.go @@ -12,6 +12,7 @@ type Deploy struct { CloudControl *CloudControl `yaml:"cloudcontrol,omitempty"` CloudFoundry *CloudFoundry `yaml:"cloudfoundry,omitempty"` EngineYard *EngineYard `yaml:"engineyard,omitempty"` + Git *Git `yaml:"git,omitempty"` Heroku *Heroku `yaml:"heroku,omitempty"` Nodejitsu *Nodejitsu `yaml:"nodejitsu,omitempty"` Openshift *Openshift `yaml:"openshift,omitempty"` @@ -30,6 +31,9 @@ func (d *Deploy) Write(f *buildfile.Buildfile) { if d.EngineYard != nil { d.EngineYard.Write(f) } + if d.Git != nil { + d.Git.Write(f) + } if d.Heroku != nil { d.Heroku.Write(f) } diff --git a/pkg/plugin/deploy/git.go b/pkg/plugin/deploy/git.go index b2b65d9ca..cc8a3e837 100644 --- a/pkg/plugin/deploy/git.go +++ b/pkg/plugin/deploy/git.go @@ -1 +1,38 @@ package deploy + +import ( + "fmt" + "github.com/drone/drone/pkg/build/buildfile" +) + +type Git struct { + Target string `yaml:"target,omitempty"` + Force bool `yaml:"force,omitempty"` + Branch string `yaml:"branch,omitempty"` +} + +func (g *Git) Write(f *buildfile.Buildfile) { + // get the current commit hash + f.WriteCmdSilent("COMMIT=$(git rev-parse HEAD)") + + // set the git user and email based on the individual + // that made the commit. + f.WriteCmdSilent("git config --global user.name $(git --no-pager log -1 --pretty=format:'%an')") + f.WriteCmdSilent("git config --global user.email $(git --no-pager log -1 --pretty=format:'%ae')") + + // add target as a git remote + f.WriteCmd(fmt.Sprintf("git remote add deploy %s", g.Target)) + + switch g.Force { + case true: + // this is useful when the there are artifacts generated + // by the build script, such as less files converted to css, + // that need to be deployed to git remote. + f.WriteCmd(fmt.Sprintf("git add -A")) + f.WriteCmd(fmt.Sprintf("git commit -m 'add build artifacts'")) + f.WriteCmd(fmt.Sprintf("git push deploy $COMMIT:master --force")) + case false: + // otherwise we just do a standard git push + f.WriteCmd(fmt.Sprintf("git push deploy $COMMIT:master")) + } +} diff --git a/pkg/plugin/notify/email.go b/pkg/plugin/notify/email.go index ed7b84759..3699a7c2a 100644 --- a/pkg/plugin/notify/email.go +++ b/pkg/plugin/notify/email.go @@ -1,30 +1,11 @@ package notify -import ( - "fmt" - "net/smtp" -) +import "github.com/drone/drone/pkg/mail" type Email struct { Recipients []string `yaml:"recipients,omitempty"` Success string `yaml:"on_success"` Failure string `yaml:"on_failure"` - - host string // smtp host address - port string // smtp host port - user string // smtp username for authentication - pass string // smtp password for authentication - from string // smtp email address. send from this address -} - -// SetServer is a function that will set the SMTP -// server location and credentials -func (e *Email) SetServer(host, port, user, pass, from string) { - e.host = host - e.port = port - e.user = user - e.pass = pass - e.from = from } // Send will send an email, either success or failure, @@ -44,11 +25,11 @@ func (e *Email) Send(context *Context) error { // recipients indicating the build failed. func (e *Email) sendFailure(context *Context) error { // loop through and email recipients - /*for _, email := range e.Recipients { - if err := mail.SendFailure(context.Repo.Slug, email, context); err != nil { + for _, email := range e.Recipients { + if err := mail.SendFailure(context.Repo.Name, email, context); err != nil { return err } - }*/ + } return nil } @@ -56,30 +37,10 @@ func (e *Email) sendFailure(context *Context) error { // recipients indicating the build was a success. func (e *Email) sendSuccess(context *Context) error { // loop through and email recipients - /*for _, email := range e.Recipients { - if err := mail.SendSuccess(context.Repo.Slug, email, context); err != nil { + for _, email := range e.Recipients { + if err := mail.SendSuccess(context.Repo.Name, email, context); err != nil { return err } - }*/ + } return nil } - -// send is a simple helper function to format and -// send an email message. -func (e *Email) send(to, subject, body string) error { - // Format the raw email message body - raw := fmt.Sprintf(emailTemplate, e.from, to, subject, body) - auth := smtp.PlainAuth("", e.user, e.pass, e.host) - addr := fmt.Sprintf("%s:%s", e.host, e.port) - - return smtp.SendMail(addr, auth, e.from, []string{to}, []byte(raw)) -} - -// text-template used to generate a raw Email message -var emailTemplate = `From: %s -To: %s -Subject: %s -MIME-version: 1.0 -Content-Type: text/html; charset="UTF-8" - -%s` diff --git a/pkg/plugin/notify/notification.go b/pkg/plugin/notify/notification.go index 0b80ae4d8..a4c6feabf 100644 --- a/pkg/plugin/notify/notification.go +++ b/pkg/plugin/notify/notification.go @@ -8,7 +8,7 @@ import ( // in-progress build request. type Context struct { // Global settings - Settings *model.Settings + Host string // User that owns the repository User *model.User @@ -35,9 +35,9 @@ type Notification struct { func (n *Notification) Send(context *Context) error { // send email notifications - //if n.Email != nil && n.Email.Enabled { - // n.Email.Send(context) - //} + if n.Email != nil { + n.Email.Send(context) + } // send email notifications if n.Webhook != nil { diff --git a/pkg/plugin/publish/s3.go b/pkg/plugin/publish/s3.go index cfa75e7d1..079be15c0 100644 --- a/pkg/plugin/publish/s3.go +++ b/pkg/plugin/publish/s3.go @@ -51,6 +51,14 @@ type S3 struct { } func (s *S3) Write(f *buildfile.Buildfile) { + + // skip if AWS key or SECRET are empty. A good example for this would + // be forks building a project. S3 might be configured in the source + // repo, but not in the fork + if len(s.Key) == 0 || len(s.Secret) == 0 { + return + } + // install the AWS cli using PIP f.WriteCmdSilent("[ -f /usr/bin/sudo ] || pip install awscli 1> /dev/null 2> /dev/null") f.WriteCmdSilent("[ -f /usr/bin/sudo ] && sudo pip install awscli 1> /dev/null 2> /dev/null") @@ -65,8 +73,8 @@ func (s *S3) Write(f *buildfile.Buildfile) { // make sure a default access is set // let's be conservative and assume private - if len(s.Region) == 0 { - s.Region = "private" + if len(s.Access) == 0 { + s.Access = "private" } // if the target starts with a "/" we need diff --git a/pkg/queue/queue.go b/pkg/queue/queue.go index 643df98de..e931c46f0 100644 --- a/pkg/queue/queue.go +++ b/pkg/queue/queue.go @@ -4,14 +4,15 @@ import ( "bytes" "fmt" bldr "github.com/drone/drone/pkg/build" + "github.com/drone/drone/pkg/build/git" r "github.com/drone/drone/pkg/build/repo" "github.com/drone/drone/pkg/build/script" - "github.com/drone/drone/pkg/build/script/notification" "github.com/drone/drone/pkg/channel" "github.com/drone/drone/pkg/database" - "github.com/drone/drone/pkg/mail" . "github.com/drone/drone/pkg/model" + "github.com/drone/drone/pkg/plugin/notify" "github.com/drone/go-github/github" + "log" "path/filepath" "time" ) @@ -93,7 +94,7 @@ func (b *BuildTask) execute() error { settings, _ := database.GetSettings() // notification context - context := ¬ification.Context{ + context := ¬ify.Context{ Repo: b.Repo, Commit: b.Commit, Host: settings.URL().String(), @@ -104,6 +105,11 @@ func (b *BuildTask) execute() error { b.Script.Notifications.Send(context) } + // Send "started" notification to Github + if err := updateGitHubStatus(b.Repo, b.Commit); err != nil { + log.Printf("error updating github status: %s\n", err.Error()) + } + // make sure a channel exists for the repository, // the commit, and the commit output (TODO) reposlug := fmt.Sprintf("%s/%s/%s", b.Repo.Host, b.Repo.Owner, b.Repo.Name) @@ -130,10 +136,19 @@ func (b *BuildTask) execute() error { // execute the build builder := bldr.Builder{} builder.Build = b.Script - builder.Repo = &r.Repo{Path: b.Repo.URL, Branch: b.Commit.Branch, Commit: b.Commit.Hash, PR: b.Commit.PullRequest, Dir: filepath.Join("/var/cache/drone/src", b.Repo.Slug)} + builder.Repo = &r.Repo{Path: b.Repo.URL, Branch: b.Commit.Branch, Commit: b.Commit.Hash, PR: b.Commit.PullRequest, Dir: filepath.Join("/var/cache/drone/src", b.Repo.Slug), Depth: git.GitDepth(b.Script.Git)} builder.Key = []byte(b.Repo.PrivateKey) builder.Stdout = buf builder.Timeout = 300 * time.Minute + + defer func() { + // update the status of the commit using the + // GitHub status API. + if err := updateGitHubStatus(b.Repo, b.Commit); err != nil { + log.Printf("error updating github status: %s\n", err.Error()) + } + }() + buildErr := builder.Run() b.Build.Finished = time.Now().UTC() @@ -169,24 +184,11 @@ func (b *BuildTask) execute() error { channel.SendJSON(commitslug, b.Build) channel.Close(consoleslug) - // add the smtp address to the notificaitons - //if b.Script.Notifications != nil && b.Script.Notifications.Email != nil { - // b.Script.Notifications.Email.SetServer(settings.SmtpServer, settings.SmtpPort, - // settings.SmtpUsername, settings.SmtpPassword, settings.SmtpAddress) - //} - // send all "finished" notifications if b.Script.Notifications != nil { - b.sendEmail(context) // send email from queue, not from inside /build/script package b.Script.Notifications.Send(context) } - // update the status of the commit using the - // GitHub status API. - if err := updateGitHubStatus(b.Repo, b.Commit); err != nil { - return err - } - return nil } @@ -197,14 +199,14 @@ func updateGitHubStatus(repo *Repo, commit *Commit) error { // convert from drone status to github status var message, status string - switch status { + switch commit.Status { case "Success": status = "success" message = "The build succeeded on drone.io" case "Failure": status = "failure" message = "The build failed on drone.io" - case "Pending": + case "Started": status = "pending" message = "The build is pending on drone.io" default: @@ -218,56 +220,17 @@ func updateGitHubStatus(repo *Repo, commit *Commit) error { // get the user from the database // since we need his / her GitHub token user, err := database.GetUser(repo.UserID) - if err == nil { + if err != nil { return err } client := github.New(user.GithubToken) - return client.Repos.CreateStatus(repo.Owner, repo.Name, status, settings.URL().String(), message, commit.Hash) -} + client.ApiUrl = settings.GitHubApiUrl; -func (t *BuildTask) sendEmail(c *notification.Context) error { - // make sure a notifications object exists - if t.Script.Notifications == nil && t.Script.Notifications.Email != nil { - return nil - } + var url string + url = settings.URL().String() + "/" + repo.Slug + "/commit/" + commit.Hash - switch { - case t.Commit.Status == "Success" && t.Script.Notifications.Email.Success != "never": - return t.sendSuccessEmail(c) - case t.Commit.Status == "Failure" && t.Script.Notifications.Email.Failure != "never": - return t.sendFailureEmail(c) - default: - println("sending nothing") - } - - return nil -} - -// sendFailure sends email notifications to the list of -// recipients indicating the build failed. -func (t *BuildTask) sendFailureEmail(c *notification.Context) error { - - // loop through and email recipients - for _, email := range t.Script.Notifications.Email.Recipients { - if err := mail.SendFailure(t.Repo.Name, email, c); err != nil { - return err - } - } - return nil -} - -// sendSuccess sends email notifications to the list of -// recipients indicating the build was a success. -func (t *BuildTask) sendSuccessEmail(c *notification.Context) error { - - // loop through and email recipients - for _, email := range t.Script.Notifications.Email.Recipients { - if err := mail.SendSuccess(t.Repo.Name, email, c); err != nil { - return err - } - } - return nil + return client.Repos.CreateStatus(repo.Owner, repo.Name, status, url, message, commit.Hash) } type bufferWrapper struct { diff --git a/pkg/template/emails/success.html b/pkg/template/emails/success.html index a5d9a4b9e..31facdfe4 100644 --- a/pkg/template/emails/success.html +++ b/pkg/template/emails/success.html @@ -1,4 +1,4 @@ -{{ define "title" }}FAILURE{{end}} +{{ define "title" }}SUCCESS{{end}} {{ define "content" }} @@ -25,4 +25,4 @@ {{ .Commit.Message }} -{{ end }} \ No newline at end of file +{{ end }} diff --git a/pkg/template/pages/admin_settings.html b/pkg/template/pages/admin_settings.html index 76097d610..d3f1b66ee 100644 --- a/pkg/template/pages/admin_settings.html +++ b/pkg/template/pages/admin_settings.html @@ -33,6 +33,9 @@ {{ end }} +
GitHub OAuth Consumer Key and Secret
@@ -41,6 +44,11 @@
+ +
+ + +
Bitbucket OAuth Consumer Key and Secret.
diff --git a/pkg/template/pages/admin_users_add.html b/pkg/template/pages/admin_users_add.html index 08d379fd1..f0d2ee750 100644 --- a/pkg/template/pages/admin_users_add.html +++ b/pkg/template/pages/admin_users_add.html @@ -59,9 +59,9 @@ if (this.status == 200) { var msg = "User Invitation was sent successfully"; if (this.responseText != "OK") { - msg = "Email is not currently enabled. In order to invite the user, you'll need to provide them the following link: " + this.responseText; + msg = "Email is not currently enabled. In order to invite the user, you'll need to provide them the following link:
" + this.responseText + ""; } - $("#successAlert").text(msg); + $("#successAlert").html(msg); $("#successAlert").show().removeClass("hide"); $('#submitButton').button('reset') @@ -75,4 +75,4 @@ return false; } -{{ end }} \ No newline at end of file +{{ end }} diff --git a/pkg/template/pages/base.html b/pkg/template/pages/base.html index 5af15d959..840f8c750 100644 --- a/pkg/template/pages/base.html +++ b/pkg/template/pages/base.html @@ -38,7 +38,7 @@ +
-
+
Enter your repository details Re-Link Account
+
@@ -84,7 +86,8 @@ if (this.status == 200) { var name = $("input[name=name]").val() var owner = $("input[name=owner]").val() - window.location.pathname = "/github.com/"+owner+"/"+name + var domain = $("input[name=domain]").val() + window.location.pathname = "/" + domain + "/"+owner+"/"+name } else { $("#failureAlert").text("Unable to setup the Repository"); $("#failureAlert").show().removeClass("hide"); diff --git a/pkg/template/pages/github_link.html b/pkg/template/pages/github_link.html index 1898f47f9..a59d0294d 100644 --- a/pkg/template/pages/github_link.html +++ b/pkg/template/pages/github_link.html @@ -14,11 +14,12 @@ -
+
Link Your GitHub Account Link Now
diff --git a/pkg/template/pages/install.html b/pkg/template/pages/install.html index 3a6c848eb..942a81c4c 100644 --- a/pkg/template/pages/install.html +++ b/pkg/template/pages/install.html @@ -2,20 +2,23 @@ {{ define "content" }}

Installation

-
- - - -
-
- - - -
+ +
+ + + +
+
+ + + +
+ {{ end }} {{ define "script" }} {{ end }} diff --git a/pkg/template/pages/login.html b/pkg/template/pages/login.html index 99b0ee5f1..2fdcc7a3f 100644 --- a/pkg/template/pages/login.html +++ b/pkg/template/pages/login.html @@ -10,7 +10,11 @@
+ {{ if .Settings.OpenInvitations }} + request invitation | forgot password + {{ else }} forgot password + {{ end }}
{{ end }} diff --git a/pkg/template/pages/members_add.html b/pkg/template/pages/members_add.html index f3e3e0333..2d4f0ea1c 100644 --- a/pkg/template/pages/members_add.html +++ b/pkg/template/pages/members_add.html @@ -69,9 +69,9 @@ if (this.status == 200) { var msg = "An invitation has been sent (via email) to join the Team."; if (this.responseText != "OK") { - msg = "Email is not currently enabled. In order to invite this team member user, you'll need to provide them the following link: " + this.responseText; + msg = "Email is not currently enabled. In order to invite this team member user, you'll need to provide them the following link:
" + this.responseText + ""; } - $("#successAlert").text(msg); + $("#successAlert").html(msg); $("#successAlert").show().removeClass("hide"); $('#submitButton').button('reset') } else { @@ -84,4 +84,4 @@ return false; } -{{ end }} \ No newline at end of file +{{ end }} diff --git a/pkg/template/pages/repo_badges.html b/pkg/template/pages/repo_badges.html index 10d8a2acc..d6231ebba 100644 --- a/pkg/template/pages/repo_badges.html +++ b/pkg/template/pages/repo_badges.html @@ -34,11 +34,11 @@
- +
- +
@@ -47,4 +47,4 @@ {{ end }} {{ define "script" }} -{{ end }} \ No newline at end of file +{{ end }} diff --git a/pkg/template/pages/repo_commit.html b/pkg/template/pages/repo_commit.html index acadcdec3..985e733b4 100644 --- a/pkg/template/pages/repo_commit.html +++ b/pkg/template/pages/repo_commit.html @@ -55,6 +55,66 @@ $(".timeago").timeago(); }); + + + diff --git a/pkg/template/pages/repo_dashboard.html b/pkg/template/pages/repo_dashboard.html index f54600739..110c4119b 100644 --- a/pkg/template/pages/repo_dashboard.html +++ b/pkg/template/pages/repo_dashboard.html @@ -19,7 +19,7 @@
- + 0 new @@ -33,7 +33,7 @@ {{.HashShort}} {{ if .PullRequest }} -

opened pull request # {{.PullRequest}}

+

opened pull request # {{.PullRequest}}

{{ else }}

{{.Message}}  

{{ end }} @@ -49,7 +49,7 @@