commit d5e5797934b80e0203d0f47b207c4848cfc834bf Author: Brad Rydzewski Date: Fri Feb 7 03:10:01 2014 -0700 initial public commit diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 000000000..f43edc7bf --- /dev/null +++ b/.drone.yml @@ -0,0 +1,24 @@ +image: mischief/docker-golang +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 + - make deps + - make + - make test + - make dpkg +notify: + email: + recipients: + - brad@drone.io +publish: + s3: + acl: public-read + region: us-east-1 + bucket: downloads.drone.io + access_key: $AWS_KEY + secret_key: $AWS_SECRET + source: /tmp/drone.deb + target: latest/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..f5ba54803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +drone.sublime-project +drone.sublime-workspace +*~ +~* +*.sqlite +*.deb +*.rice-box.go + +bin/drone +bin/droned +cmd/drone/drone +cmd/droned/droned +deb/drone/usr/local/bin/drone +deb/drone/usr/local/bin/droned diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 000000000..deb3b8cc3 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,5 @@ +# This file lists all individuals having contributed content to the repository. +# If you're submitting a patch, please add your name here in alphabetical order as part of the patch. + +Brad Rydzewski +Thomas Burke \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..7a4a3ea24 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..a8fe1a6a1 --- /dev/null +++ b/Makefile @@ -0,0 +1,78 @@ + +all: embed build + +deps: + 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 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/drone/go-github/github + go get github.com/drone/go-bitbucket/bitbucket + go get github.com/GeertJohan/go.rice + go get github.com/GeertJohan/go.rice/rice + go get github.com/mattn/go-sqlite3 + go get github.com/russross/meddler + +embed: + cd cmd/droned && rice embed + cd pkg/template && rice embed + +build: + cd cmd/drone && go build -o ../../bin/drone + cd cmd/droned && go build -o ../../bin/droned + +test: + go test -v github.com/drone/drone/pkg/build + go test -v github.com/drone/drone/pkg/build/buildfile + go test -v github.com/drone/drone/pkg/build/docker + go test -v github.com/drone/drone/pkg/build/dockerfile + go test -v github.com/drone/drone/pkg/build/proxy + go test -v github.com/drone/drone/pkg/build/repo + go test -v github.com/drone/drone/pkg/build/script + 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/testing + go test -v github.com/drone/drone/pkg/mail + go test -v github.com/drone/drone/pkg/model + go test -v github.com/drone/drone/pkg/queue + +install: + cp deb/drone/etc/init/drone.conf /etc/init/drone.conf + cd bin && install -t /usr/local/bin drone + cd bin && install -t /usr/local/bin droned + mkdir -p /var/lib/drone + +clean: + cd cmd/droned && rice clean + cd pkg/template && rice clean + rm -rf cmd/drone/drone + rm -rf cmd/droned/droned + rm -rf cmd/droned/drone.sqlite + rm -rf bin/drone + rm -rf bin/droned + rm -rf deb/drone.deb + rm -rf usr/local/bin/drone + rm -rf usr/local/bin/droned + rm -rf drone.sqlite + +# creates a debian package for drone +# to install `sudo dpkg -i drone.deb` +dpkg: + mkdir -p deb/drone/usr/local/bin + mkdir -p deb/drone/var/lib/drone + cp bin/drone deb/drone/usr/local/bin + cp bin/droned deb/drone/usr/local/bin + dpkg-deb --build deb/drone + +run: + bin/droned --port=":8080" --datasource="/tmp/drone.sqlite" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..45aca82d7 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +Drone is a Continuous Integration platform built on Docker + +### System + +Drone is tested on the following versions of Ubuntu: + +* Ubuntu Precise 12.04 (LTS) (64-bit) +* Ubuntu Raring 13.04 (64 bit) + +Drone's only external dependency is the latest version of Docker (0.8) + +### Setup + +Drone is packaged and distributed as a debian file. You can download an install +using the following commands: + +```sh +$ wget http://downloads.drone.io/latest/drone.deb +$ 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. + +### Builds + +Drone use a **.drone.yml** configuration file in the root of your +repository to run your build: + +``` +image: mischief/docker-golang +env: + - GOPATH=/var/cache/drone +script: + - go build + - go test -v +service: + - redis +notify: + email: + recipients: + - brad@drone.io + - burke@drone.io +``` + +### Environment + +Drone clones your repository into a Docker container +at the following location: + +``` +/var/cache/drone/src/github.com/$owner/$name +``` + +Please take this into consideration when setting up your build image. For example, +you may need set the $GOAPTH or other environment variables appropriately. + +### Databases + +Drone can launch database containers for your build: + +``` +service: + - cassandra + - couchdb + - elasticsearch + - neo4j + - mongodb + - mysql + - postgres + - rabbitmq + - redis + - riak + - zookeeper +``` + +**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 +IP address. + +### Deployments + +Drone can trigger a deployment at the successful completion of your build: + +``` +deploy: + heroku: + app: safe-island-6261 + +publish: + s3: + acl: public-read + region: us-east-1 + bucket: downloads.drone.io + access_key: C24526974F365C3B + secret_key: 2263c9751ed084a68df28fd2f658b127 + source: /tmp/drone.deb + target: latest/ + +``` + +### Notifications + +Drone can trigger email, hipchat and web hook notification at the completion +of your build: + +``` +notify: + email: + recipients: + - brad@drone.io + - burke@drone.io + + urls: + - http://my-deploy-hook.com + + hipchat: + room: support + token: 3028700e5466d375 +``` + +### Docs + +Coming Soon to [drone.readthedocs.org](http://drone.readthedocs.org/) + + diff --git a/bin/README.md b/bin/README.md new file mode 100644 index 000000000..4e2a5f731 --- /dev/null +++ b/bin/README.md @@ -0,0 +1 @@ +This is where Drone binaries go after running `make` in the Drone root directory. \ No newline at end of file diff --git a/cmd/drone/drone.go b/cmd/drone/drone.go new file mode 100644 index 000000000..e3f97ee3b --- /dev/null +++ b/cmd/drone/drone.go @@ -0,0 +1,288 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "sync" + "time" + + "github.com/drone/drone/pkg/build" + "github.com/drone/drone/pkg/build/log" + "github.com/drone/drone/pkg/build/repo" + "github.com/drone/drone/pkg/build/script" + + "launchpad.net/goyaml" +) + +var ( + // identity file (id_rsa) that will be injected + // into the container if specified + identity = flag.String("identity", "", "") + + // runs Drone in parallel mode if True + parallel = flag.Bool("parallel", false, "") + + // build will timeout after N milliseconds. + // this will default to 500 minutes (6 hours) + timeout = flag.Duration("timeout", 300*time.Minute, "") + + // runs Drone with verbose output if True + verbose = flag.Bool("v", false, "") + + // displays the help / usage if True + help = flag.Bool("h", false, "") +) + +func init() { + // default logging + log.SetPrefix("\033[2m[DRONE] ") + log.SetSuffix("\033[0m\n") + log.SetOutput(os.Stdout) + log.SetPriority(log.LOG_NOTICE) +} + +func main() { + // Parse the input parameters + flag.Usage = usage + flag.Parse() + + if *help { + flag.Usage() + os.Exit(0) + } + + if *verbose { + log.SetPriority(log.LOG_DEBUG) + } + + // Must speicify a command + args := flag.Args() + if len(args) == 0 { + flag.Usage() + os.Exit(0) + } + + switch { + // run drone build assuming the current + // working directory contains the drone.yml + case args[0] == "build" && len(args) == 1: + path, _ := os.Getwd() + path = filepath.Join(path, ".drone.yml") + run(path) + + // run drone build where the path to the + // source directory is provided + case args[0] == "build" && len(args) == 2: + path := args[1] + path = filepath.Clean(path) + path, _ = filepath.Abs(path) + path = filepath.Join(path, ".drone.yml") + run(path) + + // run drone vet where the path to the + // source directory is provided + case args[0] == "vet" && len(args) == 2: + path := args[1] + path = filepath.Clean(path) + path, _ = filepath.Abs(path) + path = filepath.Join(path, ".drone.yml") + vet(path) + + // run drone vet assuming the current + // working directory contains the drone.yml + case args[0] == "vet" && len(args) == 1: + path, _ := os.Getwd() + path = filepath.Join(path, ".drone.yml") + vet(path) + + // print the help message + case args[0] == "help" && len(args) == 1: + flag.Usage() + } + + os.Exit(0) +} + +func vet(path string) { + // parse the Drone yml file + script, err := script.ParseBuildFile(path) + if err != nil { + log.Err(err.Error()) + os.Exit(1) + return + } + + // print the Drone yml as parsed + out, _ := goyaml.Marshal(script) + log.Noticef("parsed yaml:\n%s", string(out)) +} + +func run(path string) { + // parse the Drone yml file + s, err := script.ParseBuildFile(path) + if err != nil { + log.Err(err.Error()) + os.Exit(1) + return + } + + // get the repository root directory + dir := filepath.Dir(path) + code := repo.Repo{Path: dir} + + // does the local repository match the + // $GOPATH/src/{package} pattern? This is + // important so we know the target location + // where the code should be copied inside + // the container. + if gopath, ok := getRepoPath(dir); ok { + code.Dir = gopath + + } else if gopath, ok := getGoPath(dir); ok { + // in this case we found a GOPATH and + // reverse engineered the package path + code.Dir = gopath + + } else { + // otherwise just use directory name + code.Dir = filepath.Base(dir) + } + + // this is where the code gets uploaded to the container + // TODO move this code to the build package + code.Dir = filepath.Join("/var/cache/drone/src", filepath.Clean(code.Dir)) + + // track all build results + var builders []*build.Builder + + // ssh key to import into container + var key []byte + 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) + os.Exit(1) + return + } + } + + builds := []*script.Build{s} + + // loop through and create builders + for _, b := range builds { //script.Builds { + builder := build.Builder{} + builder.Build = b + builder.Repo = &code + builder.Key = key + builder.Stdout = os.Stdout + builder.Timeout = *timeout + + if *parallel == true { + var buf bytes.Buffer + builder.Stdout = &buf + } + + builders = append(builders, &builder) + } + + switch *parallel { + case false: + runSequential(builders) + case true: + runParallel(builders) + } + + // if in parallel mode, print out the buffer + // if we had a failure + for _, builder := range builders { + if builder.BuildState.ExitCode == 0 { + continue + } + + if buf, ok := builder.Stdout.(*bytes.Buffer); ok { + log.Noticef("printing stdout for failed build %s", builder.Build.Name) + println(buf.String()) + } + } + + // this exit code is initially 0 and will + // be set to an error code if any of the + // builds fail. + var exit int + + fmt.Printf("\nDrone Build Results \033[90m(%v)\033[0m\n", len(builders)) + + // loop through and print results + for _, builder := range builders { + build := builder.Build + res := builder.BuildState + duration := time.Duration(res.Finished - res.Started) + switch { + case builder.BuildState.ExitCode == 0: + fmt.Printf(" \033[32m\u2713\033[0m %v \033[90m(%v)\033[0m\n", build.Name, humanizeDuration(duration*time.Second)) + case builder.BuildState.ExitCode != 0: + fmt.Printf(" \033[31m\u2717\033[0m %v \033[90m(%v)\033[0m\n", build.Name, humanizeDuration(duration*time.Second)) + exit = builder.BuildState.ExitCode + } + } + + os.Exit(exit) +} + +func runSequential(builders []*build.Builder) { + // loop through and execute each build + for _, builder := range builders { + if err := builder.Run(); err != nil { + log.Errf("Error executing build: %s", err.Error()) + os.Exit(1) + } + } +} + +func runParallel(builders []*build.Builder) { + // spawn four worker goroutines + var wg sync.WaitGroup + for _, builder := range builders { + // Increment the WaitGroup counter + wg.Add(1) + // Launch a goroutine to run the build + go func(builder *build.Builder) { + defer wg.Done() + builder.Run() + }(builder) + time.Sleep(500 * time.Millisecond) // get weird iptables failures unless we sleep. + } + + // wait for the workers to finish + wg.Wait() +} + +var usage = func() { + fmt.Println(`Drone is a tool for building and testing code in Docker containers. + +Usage: + + drone command [arguments] + +The commands are: + + build build and test the repository + version print the version number + vet validate the yaml configuration file + + -v runs drone with verbose output + -h display this help and exit + --parallel runs drone build tasks in parallel + --timeout=300ms timeout build after 300 milliseconds + +Examples: + drone build builds the source in the pwd + drone build /path/to/repo builds the source repository + +Use "drone help [command]" for more information about a command. +`) +} diff --git a/cmd/drone/util.go b/cmd/drone/util.go new file mode 100644 index 000000000..a5f628ca1 --- /dev/null +++ b/cmd/drone/util.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// getGoPath checks the source codes absolute path +// in reference to the host operating system's GOPATH +// to correctly determine the code's package path. This +// is Go-specific, since Go code must exist in +// $GOPATH/src/github.com/{owner}/{name} +func getGoPath(dir string) (string, bool) { + path := os.Getenv("GOPATH") + if len(path) == 0 { + return "", false + } + // append src to the GOPATH, since + // the code will be stored in the src dir + path = filepath.Join(path, "src") + if !filepath.HasPrefix(dir, path) { + return "", false + } + + // remove the prefix from the directory + // this should leave us with the go package name + return dir[len(path):], true +} + +var gopathExp = regexp.MustCompile("./src/(github.com/[^/]+/[^/]+|bitbucket.org/[^/]+/[^/]+|code.google.com/[^/]+/[^/]+)") + +// getRepoPath checks the source codes absolute path +// on the host operating system in an attempt +// to correctly determine the code's package path. This +// is Go-specific, since Go code must exist in +// $GOPATH/src/github.com/{owner}/{name} +func getRepoPath(dir string) (path string, ok bool) { + // let's get the package directory based + // on the path in the host OS + indexes := gopathExp.FindStringIndex(dir) + if len(indexes) == 0 { + return + } + + index := indexes[len(indexes)-1] + + // if the dir is /home/ubuntu/go/src/github.com/foo/bar + // the index will start at /src/github.com/foo/bar. + // We'll need to strip "/src/" which is where the + // magic number 5 comes from. + index = strings.LastIndex(dir, "/src/") + return dir[index+5:], true +} + +// getGitOrigin checks the .git origin in an attempt +// to correctly determine the code's package path. This +// is Go-specific, since Go code must exist in +// $GOPATH/src/github.com/{owner}/{name} +func getGitOrigin(dir string) (path string, ok bool) { + // TODO + return +} + +// prints the time as a human readable string +func humanizeDuration(d time.Duration) string { + if seconds := int(d.Seconds()); seconds < 1 { + return "Less than a second" + } else if seconds < 60 { + return fmt.Sprintf("%d seconds", seconds) + } else if minutes := int(d.Minutes()); minutes == 1 { + return "About a minute" + } else if minutes < 60 { + return fmt.Sprintf("%d minutes", minutes) + } else if hours := int(d.Hours()); hours == 1 { + return "About an hour" + } else if hours < 48 { + return fmt.Sprintf("%d hours", hours) + } else if hours < 24*7*2 { + return fmt.Sprintf("%d days", hours/24) + } else if hours < 24*30*3 { + return fmt.Sprintf("%d weeks", hours/24/7) + } else if hours < 24*365*2 { + return fmt.Sprintf("%d months", hours/24/30) + } + return fmt.Sprintf("%f years", d.Hours()/24/365) +} diff --git a/cmd/droned/assets/css/drone.css b/cmd/droned/assets/css/drone.css new file mode 100644 index 000000000..c997f02e0 --- /dev/null +++ b/cmd/droned/assets/css/drone.css @@ -0,0 +1,1076 @@ +body { + background: #FFF; +} +.container { + max-width: none !important; + width: 940px; + padding: 0px; +} +.row { + margin: 0px; + padding: 0px; +} +.col-xs-1, +.col-xs-2, +.col-xs-3, +.col-xs-4, +.col-xs-5, +.col-xs-6, +.col-xs-7, +.col-xs-8, +.col-xs-9 { + padding: 0px; +} +.row > .col-xs-2:last-child, +.row > .col-xs-3:last-child, +.row > .col-xs-4:last-child, +.row > .col-xs-5:last-child, +.row > .col-xs-6:last-child, +.row > .col-xs-7:last-child, +.row > .col-xs-8:last-child, +.row > .col-xs-9:last-child { + padding-left: 20px; +} +.navbar-inverse { + background: #262829; + margin-bottom: 0px; +} +.navbar-inverse .container { + padding-right: 0px; +} +.navbar-fixed-top, +.navbar-fixed-bottom { + position: relative; +} +.navbar-inverse .navbar-brand { + padding: 21px 20px 0px 0px; + font-weight: normal; + text-shadow: none; + font-family: 'Orbitron'; + font-size: 28px; + color: #DDDDDD; + margin-left: 0px !IMPORTANT; +} +.navbar .nav { + float: right; + margin-right: 0px; +} +.navbar .nav > li > a { + padding: 18px 20px; +} +.navbar .nav > li > a.btn { + padding: 7px 20px; + margin: 0px; + margin-top: 11px; + border-radius: 4px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + background: #363839; + border: none; + box-shadow: none; + text-shadow: none; + color: #DDD; + margin-left: 15px; +} +.navbar .nav > li > a.btn.btn-search, +.navbar .nav > li > a.btn.btn-config { + padding: 7px 15px; +} +.subhead { + margin-bottom: 22px; + background: #f4f4f4; + border: none; + position: relative; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} +.subhead h1 { + font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 26px; + font-weight: normal; + color: #777; + line-height: 55px; + margin-top: 10px; + margin-bottom: 10px; +} +.subhead h1 span { + padding-right: 10px; +} +.subhead h1 small { + color: #999; + font-size: 60%; +} +.subhead h1.user span { + padding-left: 10px; + font-size: 24px; + line-height: 24px; +} +.subhead h1.user img { + width: 42px; + height: 42px; + border-radius: 50%; + -webkit-border-radius: 50%; + -moz-border-radius: 50%; +} +.subhead h1.user small { + color: #777; + font-size: 60%; +} +.subhead .container { + position: relative; + padding: 0px; +} +.subhead .nav-tabs { + position: absolute; + right: 0px; + bottom: 0px; + margin: 0px; + padding: 0px; + border-bottom: none; +} +.subhead .nav-tabs > li { + margin-bottom: 0px; +} +.subhead .nav-tabs > li > a { + color: #999; + padding: 10px 20px; + line-height: 20px; + border: 0px; +} +.subhead .nav-tabs > li.active > a { + color: #777; + border: 0px; +} +.nav-repos, +.nav-branches { + margin-top: 0px; + border: none; +} +.nav-repos li a, +.nav-branches li a { + line-height: 22px; + margin-bottom: 0px; + padding: 5px 15px; + padding: 10px 15px; + border: none; + margin: 0px !IMPORTANT; + font-size: 15px; + border-radius: 0px; + -moz-border-radius: 0px; + -webkit-border-radius: 0px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.nav-repos li a span, +.nav-branches li a span { + color: #777; +} +.nav-repos li a span [class^="icon-"], +.nav-branches li a span [class^="icon-"] { + color: #999; +} +.nav-repos li a i, +.nav-branches li a i { + padding-right: 10px; + color: #AAA; + font-size: 18px; + vertical-align: middle; +} +.nav-repos li.active a, +.nav-branches li.active a { + background: #f4f4f4; +} +.nav-repos li.active a:hover, +.nav-branches li.active a:hover { + background: #f4f4f4; +} +.nav-repos .nav-header, +.nav-branches .nav-header { + padding: 15px 15px; + margin-bottom: 0px; + color: #666; + font-size: 24px; + text-transform: lowercase; + font-family: 'Open Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: normal; + display: none; +} +.nav-repos li a, +.nav-branches li a { + padding: 12px 15px; + margin-bottom: 0px; + color: #777; +} +.nav-pills.nav-repos > li > a, +.nav-repos > li > a { + padding: 13px 15px; +} +.nav-pills.nav-repos > li:nth-child(odd), +.nav-repos > li:nth-child(odd) { + background: #f7f7f7; +} +.nav-repos > li > a span { + color: #AAA; +} +.nav-repos > li > a span:last-child { + color: #555; +} +.nav-pills.nav-branches > li > a { + padding: 10px 15px; +} +.alert { + border: none; + border-radius: 0px; + background: #f4f4f4; + color: #999; + padding: 30px; + font-size: 18px; + margin-bottom: 20px; +} +.alert .pull-right { + margin-top: -10px; +} +.alert .thumbnails { + margin: 0px; + padding: 0px; +} +.alert .thumbnails li { + margin: 0px; + padding: 0px; + margin-left: 20px; + margin-top: 20px; +} +.alert .thumbnails .thumbnail { + background: transparent; +} +.alert.alert-feed { + margin-top: 0px; + margin-bottom: 0px; + padding: 15px 20px; +} +.alert.alert-feed span { + line-height: 50px; +} +.alert.alert-feed span.label { + float: right; + color: #BBB; + font-size: 16px; + padding-right: 10px; + font-weight: normal; +} +.alert.alert-feed .thumbnails > li { + margin: 0px; + margin-left: 5px; + padding: 0px; + display: inline-block; +} +.alert.alert-feed .thumbnails > li a { + border: 0px; + padding: 0px; +} +.alert.alert-feed .thumbnails > li a img { + border-radius: 50%; + -moz-border-radius: 50%; + -webkit-border-radius: 50%; + width: 32px; + height: 32px; + margin-top: 10px; +} +.alert.alert-success { + color: #3c763d; + background-color: #dff0d8; + text-shadow: none; + font-size: 16px; + margin-top: 30px; + margin-bottom: 10px; +} +.alert.alert-error { + background: #ebccd1; + color: #a94442; + text-shadow: none; + font-size: 16px; + margin-top: 30px; + margin-bottom: 10px; +} +.commit-list { + list-style: none; + margin: 0px; + padding-left: 0px; +} +.commit-list > li { + margin: 0px; + position: relative; + padding: 25px 20px; + padding-left: 25px; + list-style: none; +} +.commit-list > li > img { + position: absolute; + right: 20px; + top: 20px; + -webkit-border-radius: 50%; + -moz-border-radius: 50%; + border-radius: 50%; + width: 48px; + height: 48px; + background: #EEE; +} +.commit-list > li:last-child { + border-bottom: none; +} +.commit-list > li:nth-child(even) { + background: #f7f7f7; +} +.commit-list.commit-list-alt > li:nth-child(even) { + background: #fff; +} +.commit-list.commit-list-alt > li:nth-child(odd) { + background: #f7f7f7; +} +.commit-list > li > h3 { + display: inline; + line-height: 18px; + height: 18px; + font-weight: normal; + color: #777; + font-size: 18px; + margin-top: 10px; +} +.commit-list > li > h3 a, +.commit-list > li > h3 span { + margin-top: 2px; + display: inline-block; +} +.commit-list > li > h3 a { + color: #555; + text-decoration: none; +} +.commit-list > li > h3 a:hover { + text-decoration: underline; +} +.commit-list > li > h3 small { + font-size: 14px; + display: inline-block; + margin-left: 5px; + white-space: nowrap; +} +.commit-list > li > h3 p { + font-size: 14px; + color: #999; + margin: 0px; + padding: 0px; + padding-top: 2px; + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 350px; +} +.btn.btn-Success, +.btn.btn-Failure, +.btn.btn-Pending, +.btn.btn-Started, +.btn.btn-Error, +.btn.btn-None { + border: none; + background: #BBB; + color: #FFF; + text-shadow: none; + box-shadow: none; + padding: 10px 15px; + min-width: 75px; + float: left; + margin-right: 25px; + font-size: 16px; + text-transform: none; + font-weight: normal; + padding: 0px; + border-bottom: 0px; + width: 48px; + height: 48px; + min-height: 48px; + max-height: 48px; + min-width: 48px; + max-width: 48px; + border-radius: 50%; + -webkit-border-radius: 50%; + -moz-border-radius: 50%; +} +.btn.btn-success, +.btn.btn-Success { + background: rgba(81, 163, 81, 0.75); +} +.btn.btn-failure, +.btn.btn-Failure, +.btn.btn-Error { + background: rgba(189, 54, 47, 0.8); +} +.btn.btn-Scheduled, +.btn.btn-Pending, +.btn.btn-Started { + background: #D5E802; +} +.btn.btn-Success:before { + content: "\f00c"; + font-family: 'FontAwesome'; + font-size: 22px; + line-height: 48px; + opacity: 0.8; + color: #FFF; +} +.btn.btn-Error:before, +.btn.btn-Failure:before { + content: "\f00d"; + font-family: 'FontAwesome'; + font-size: 22px; + line-height: 48px; + opacity: 0.8; + color: #fff; +} +.btn.btn-refresh { + position: absolute; + left: -95px; + float: left; + background: #f7f7f7; + width: 75px; + border-radius: 0px; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + text-align: center; + font-size: 22px; + text-decoration: none; + color: #999; + padding: 10px 0px; + z-index: 1; +} +.btn.btn-refresh span { + display: block; + font-size: 14px; + padding-top: 5px; +} +.btn.btn-Started:before, +.btn.btn-Scheduled:before, +.btn.btn-Pending:before { + content: "\f021"; + font-family: 'FontAwesome'; + font-size: 22px; + line-height: 48px; + color: #FFF; + display: inline-block; + -webkit-animation: spin 1.5s infinite linear; + -moz-animation: spin 1.5s infinite linear; + -ms-animation: spin 1.5s infinite linear; + -o-animation: spin 1.5s infinite linear; + animation: spin 1.5s infinite linear; +} +.btn.btn-mini { + width: 24px; + height: 24px; + max-width: 24px; + max-height: 24px; + min-width: 24px; + min-height: 24px; +} +.btn.btn-mini.btn-Success:before, +.btn.btn-mini.btn-Failure:before, +.btn.btn-mini.btn-Error:before, +.btn.btn-mini.btn-Started:before, +.btn.btn-mini.btn-Scheduled:before, +.btn.btn-mini.btn-Pending:before { + line-height: 24px !IMPORTANT; + font-size: 14px !IMPORTANT; +} +@-webkit-keyframes spin { + to { + -webkit-transform: rotate(360deg); + } +} +@-moz-keyframes spin { + to { + -moz-transform: rotate(360deg); + } +} +@-ms-keyframes spin { + to { + -ms-transform: rotate(360deg); + } +} +@-o-keyframes spin { + to { + -o-transform: rotate(360deg); + } +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.btn.btn-None { + background: rgba(81, 163, 81, 0.75); + border-bottom: 2px solid #51a351; +} +.btn.btn-default { + padding: 7px 20px; + padding-bottom: 6px; + border-bottom-width: 2px; + color: #777; +} +.btn.btn-primary { + background: rgba(251, 136, 35, 0.85); + padding: 7px 20px; + border: none; + border-bottom: 2px solid #cd6d1d; +} +.btn.btn-primary:hover, +.btn.btn-primary:active { + background: #fb8823; + border-bottom: 2px solid #cd6d1d; +} +.btn.btn-danger { + border: none; + padding: 7px 20px; + background: rgba(189, 54, 47, 0.8); + border-bottom: 2px solid #bd362f; +} +.btn.btn-danger:hover, +.btn.btn-danger:active { + background: rgba(189, 54, 47, 0.9); + border-bottom: 2px solid #bd362f; +} +.nav-pills > li a { + padding: 7px 15px; + color: #777777; +} +.nav-pills > li > a, +.nav-pills > li > a:hover, +.nav-pills > li > a:focus { + border-radius: 0px; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; +} +.nav-pills > .active { + border-radius: 0px; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; +} +.nav-pills > li.active > a, +.nav-pills > li.active > a:hover, +.nav-pills > li.active > a:focus, +.nav-pills > .active > a, +.nav-pills > .active > a:hover, +.nav-pills > .active > a:focus { + border-radius: 0px; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + margin-top: 0px; + background-color: #f4f4f4; + color: #777777; +} +form label { + color: #777; + margin-top: 25px; + margin-bottom: 7px; + margin-left: 2px; + cursor: default; + font-weight: normal; + display: block; +} +form label:first-child { + margin-top: 0px; +} +form .form-actions { + background: transparent; + padding: 30px 0px; + margin: 0px; + border: none; +} +form .form-actions .btn { + margin-right: 3px; +} +form .form-group { + margin-bottom: 30px; +} +input.form-control, +select.form-control { + display: inline-block; + height: 30px; + padding: 4px 6px; + margin-bottom: 0px; + font-size: 14px; + line-height: 20px; + color: #555555; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + vertical-align: middle; + width: 270px; +} +input.form-control-small, +select.form-control-small { + width: 90px; +} +input.form-control-large, +select.form-control-large { + width: 210px; +} +input.form-control-xlarge, +select.form-control-xlarge { + width: 270px; +} +span.form-control { + color: #999999; + background-color: #fcfcfc; + border-color: #cccccc; + -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); + -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); + cursor: not-allowed; +} +textarea { + font-family: 'Droid Sans Mono', monospace; +} +.avatar-link { + float: right; + position: relative; + background: #eee; + padding: 5px; + text-decoration: none; +} +.avatar-link span { + display: block; + text-align: center; + color: #777; + padding: 5px; +} +.avatar-link img { + width: 160px; + height: 160px; + max-width: 160px; + max-height: 160px; + min-width: 160px; + min-height: 160px; +} +.avatar-link:hover { + text-decoration: none; +} +.avatar-link:hover:before { + color: #fff; + text-shadow: 1px 1px rgba(0, 0, 0, 0.5); + font-family: 'FontAwesome'; + content: "\f044"; + position: absolute; + right: 10px; + top: 10px; + font-size: 16px; +} +.row.gravatar-list { + margin-left: 0px; +} +.row.gravatar-list .col-xs-3 { + width: 23.404255319148934%; + margin: 0px; + padding: 0px; + margin-right: 2.127659574468085%; +} +.row.gravatar-list .col-xs-3:nth-child(4) { + margin-right: 0px; +} +.row.gravatar-list .col-xs-3 a.thumbnail, +.row.gravatar-list .col-xs-3 a.thumbnail:hover { + border-radius: 0px; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + box-shadow: none; + border: none; + text-decoration: none; + background: #f4f4f4; +} +.row.gravatar-list .col-xs-3 a.thumbnail img, +.row.gravatar-list .col-xs-3 a.thumbnail:hover img { + width: 156px; + height: 156px; +} +.row.gravatar-list .col-xs-3 a.thumbnail .caption > h3, +.row.gravatar-list .col-xs-3 a.thumbnail:hover .caption > h3 { + font-weight: normal; + text-align: center; + font-size: 15px; + padding: 0px; + line-height: 16px; + margin: 0px; + max-width: 156px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: #555555; +} +.row.gravatar-list .col-xs-3 a.thumbnail.team-add > span, +.row.gravatar-list .col-xs-3 a.thumbnail:hover.team-add > span { + min-width: 156px; + min-height: 156px; + display: inline-block; + text-align: center; + vertical-align: middle; + line-height: 156px; + font-size: 64px; + color: #d7d7d7; +} +.row.gravatar-list .col-xs-3 a.thumbnail.team-add .caption > h3, +.row.gravatar-list .col-xs-3 a.thumbnail:hover.team-add .caption > h3 { + color: #999; +} +/* Checkbox hack */ +.switch input, +.switch a { + cursor: pointer; + height: 100%; + opacity: 0; + position: absolute; + width: 100%; + z-index: 100; +} +/* Container */ +.switch { + height: 40px; + position: relative; + width: 100px; +} +/* Background unchecked */ +.switch label { + background: rgba(189, 54, 47, 0.8); + border-radius: 2px; + display: block; + height: 100%; + position: relative; + transition: all .15s ease; + width: 100%; + border-radius: 5px; + color: #FFF; +} +/* Slider unchecked */ +.switch label div { + background: #fff; + border-radius: 2px; + display: block; + height: 28px; + left: 6px; + position: absolute; + top: 6px; + transition: all .15s ease; + width: 35px; + z-index: 3; + border-radius: 5px; +} +/* Vertical lines on slider */ +.switch label div:before { + bottom: 0; + content: ''; + display: block; + height: 15px; + left: 0; + margin: auto; + position: absolute; + right: 0; + top: 0; + transition: all .15s ease; + width: 3px; +} +/* Icon styles */ +.switch .fontawesome-ok { + font-size: 18px; + left: 15px; + position: relative; + top: 8px; + z-index: 2; +} +.switch .fontawesome-ok:before { + font-family: 'FontAwesome'; + content: "\f00c"; + opacity: 0.8; +} +.switch .fontawesome-remove { + font-size: 18px; + left: 45px; + position: relative; + top: 8px; + z-index: 2; +} +.switch .fontawesome-remove:before { + font-family: 'FontAwesome'; + content: "\f00d"; + opacity: 0.8; +} +/* Checked States */ +.switch input:checked ~ label { + background: rgba(81, 163, 81, 0.75); +} +.switch input:checked ~ label div { + left: 58px; +} +pre { + white-space: pre-wrap; + background: #464849; + color: #FFF; + padding: 20px 20px; + border-radius: 5px; + font-size: 12px; + margin-top: 0px; + overflow: hidden; + font-family: 'Droid Sans Mono', 'monospace'; + line-height: 20px; +} +.alert.alert-build-Success, +.alert.alert-build-Error, +.alert.alert-build-Failure, +.alert.alert-build-Pending, +.alert.alert-build-Started { + text-shadow: none; + margin-top: 5px; + margin-bottom: 30px; + font-size: 15px; + padding: 15px 20px; + border-radius: 5px; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; +} +.alert.alert-build-Success span, +.alert.alert-build-Error span, +.alert.alert-build-Failure span, +.alert.alert-build-Pending span, +.alert.alert-build-Started span { + line-height: 32px; +} +.alert.alert-build-Success span span, +.alert.alert-build-Error span span, +.alert.alert-build-Failure span span, +.alert.alert-build-Pending span span, +.alert.alert-build-Started span span { + text-decoration: underline; +} +.alert.alert-build-Success a.btn, +.alert.alert-build-Error a.btn, +.alert.alert-build-Failure a.btn, +.alert.alert-build-Pending a.btn, +.alert.alert-build-Started a.btn { + width: 32px; + height: 32px; + max-width: 32px; + max-height: 32px; + min-width: 32px; + min-height: 32px; + margin-right: 20px !IMPORTANT; +} +.alert.alert-build-Success a.btn:before, +.alert.alert-build-Error a.btn:before, +.alert.alert-build-Failure a.btn:before, +.alert.alert-build-Pending a.btn:before, +.alert.alert-build-Started a.btn:before { + font-size: 22px !IMPORTANT; + line-height: 32px !IMPORTANT; +} +.build-details { + background: #FFF; + margin-bottom: 40px; +} +.build-details dt { + float: left; + width: 90px; + color: #333; + font-weight: normal; +} +.build-details dt:after { + content: ':'; +} +.build-details dd { + color: #555; + white-space: nowrap; +} +.build-details .build-summary { + float: left; + width: 250px; + padding-left: 20px; +} +.build-details img { + float: left; + border-radius: 50%; + -webkit-border-radius: 50%; + -moz-border-radius: 50%; + margin-right: 30px; + width: 58px; + height: 58px; +} +.build-details .commit-summary { + float: left; +} +.build-details.affix { + top: 0px; + padding-top: 15px; + padding-bottom: 30px; +} +.alert.alert-build-Success { + color: #468847; + background-color: #dff0d8; + border-color: #d6e9c6; +} +.alert.alert-build-Error, +.alert.alert-build-Failure { + background-color: #f2dede; + color: #b94a48; +} +.alert.alert-build-Pending, +.alert.alert-build-Started { + color: #c09853; + background: rgba(213, 232, 2, 0.2); + background-color: rgba(213, 232, 2, 0.2); +} +.form-repo .field-group { + display: inline-block; + margin-bottom: 30px; +} +.form-repo .field-group label { + margin-top: 0px; +} +.form-repo .field-separator { + display: inline-block; + font-size: 20px; +} +.form-repo ul { + padding-top: 15px; + margin-bottom: 0px; + padding-bottom: 0px; + list-style: none; + padding-left: 0px; + margin-left: 0px; +} +.form-repo ul li { + padding-bottom: 10px; +} +.form-repo ul li img { + width: 32px; + height: 32px; + border-radius: 50%; + -webkit-border-radius: 50%; + -moz-border-radius: 50%; + margin-left: 10px; + margin-right: 10px; +} +ul.account-radio-group { + padding-top: 15px; + margin-bottom: 0px; + padding-bottom: 0px; + list-style: none; + padding-left: 0px; + margin-left: 0px; +} +ul.account-radio-group li { + padding-bottom: 10px; +} +ul.account-radio-group li img { + width: 32px; + height: 32px; + border-radius: 50%; + -webkit-border-radius: 50%; + -moz-border-radius: 50%; + margin-left: 10px; + margin-right: 10px; +} +.form-centered { + background: #FFF; + overflow: hidden; + max-width: 350px; + margin: 0 auto; + text-align: center; + margin-top: 40px; + padding: 0px 0px 20px 0px; + border-radius: 5px; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; +} +.form-centered h1 { + background-color: #262829; + color: #FFF; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + font-family: 'Orbitron'; + font-weight: normal; + font-size: 18px; + padding: 25px 10px; + margin-top: 0px; + margin-bottom: 30px; +} +.form-centered input[type="submit"] { + border: none; + color: #FFF; + padding: 10px; + font-size: 18px; + margin-top: 30px; + margin-bottom: 10px; + width: 280px; + background: #AAA; + border-radius: 5px; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; +} +.form-centered input[type="text"], +.form-centered input[type="password"] { + margin-bottom: 0px; + font-size: 16px; + padding: 10px; + width: 280px; + height: 40px; +} +.form-centered input[type="text"]:last-child, +.form-centered input[type="password"]:last-child { + border-top-width: 0px; + border-top-right-radius: 0px; + border-top-left-radius: 0px; +} +.form-centered input[type="text"]:first-child, +.form-centered input[type="password"]:first-child { + border-bottom-right-radius: 0px; + border-bottom-left-radius: 0px; +} +.form-centered input[type="text"][disabled], +.form-centered input[type="password"][disabled] { + box-shadow: none; + background: #f7f7f7; + color: #999; + text-shadow: none; +} +.form-centered input[type="text"].only-child, +.form-centered input[type="password"].only-child { + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + border-top-right-radius: 4px; + border-top-left-radius: 4px; + border-bottom-width: 1px; + border-top-width: 1px; +} +.form-centered a, +.form-centered a:visited { + color: #999; + text-decoration: underline; +} +.form-centered h1 ~ div.alert { + margin-top: -30px; +} +.form-centered .alert.alert-danger { + background: #ebccd1; + color: #a94442; + text-shadow: none; + font-weight: normal; + font-size: 16px; + padding: 20px; + margin-bottom: 30px; +} +.form-centered .alert.alert-success { + color: #3c763d; + background-color: #dff0d8; + text-shadow: none; + font-weight: normal; + font-size: 16px; + padding: 20px; + margin-bottom: 30px; +} diff --git a/cmd/droned/assets/css/drone.less b/cmd/droned/assets/css/drone.less new file mode 100644 index 000000000..1ed5070d3 --- /dev/null +++ b/cmd/droned/assets/css/drone.less @@ -0,0 +1,1263 @@ +// I SINCERELY APOLOGIZE FOR ANYONE READING AND TRYING +// TO UNDERSTAND THIS FILE. I PROMISE I WILL REFACTOR +// AND CLEAN UP THIS MESS AS SOON AS I HAVE TIME! + +body { + background:#FFF; +} + +// here we need to reset the container so that we are +// displaying the page in non-responsive mode. +.container { + max-width: none !important; + width: 940px; + padding:0px; +} + +// as part of displaying the page in non-responsive +// mode we need to remove some weird negative margins +// that exist on the rows, and columns. +.row { + margin:0px; + padding:0px; +} + +.col-xs-1, +.col-xs-2, +.col-xs-3, +.col-xs-4, +.col-xs-5, +.col-xs-6, +.col-xs-7, +.col-xs-8, +.col-xs-9 { + padding:0px; +} + + +.row > .col-xs-2:last-child, +.row > .col-xs-3:last-child, +.row > .col-xs-4:last-child, +.row > .col-xs-5:last-child, +.row > .col-xs-6:last-child, +.row > .col-xs-7:last-child, +.row > .col-xs-8:last-child, +.row > .col-xs-9:last-child, { + padding-left:20px; +} + +//--------------------------------------------------------------------- +// Navigation Bar + +.navbar-inverse { + background: #262829; + margin-bottom: 0px; + .container { + padding-right:0px; + } +} + +.navbar-fixed-top, .navbar-fixed-bottom { + position:relative; +} + +.navbar-inverse .navbar-brand { + padding: 21px 20px 0px 0px; + font-weight: normal; + text-shadow: none; + font-family: 'Orbitron'; + font-size: 28px; + color: #DDDDDD; + margin-left: 0px !IMPORTANT; +} + +.navbar .nav { + float:right; + margin-right:0px; +} + +.navbar .nav > li > a { + padding: 18px 20px; +} + +.navbar .nav > li > a.btn { + padding: 7px 20px; + margin: 0px; + margin-top:11px; + // TODO this should use a custom LESS function + border-radius:4px; + -webkit-border-radius:4px; + -moz-border-radius:4px; + background:#363839; + border: none; + box-shadow: none; + text-shadow: none; + color: #DDD; + margin-left: 15px; +} + +.navbar .nav > li > a.btn.btn-search, +.navbar .nav > li > a.btn.btn-config { + padding: 7px 15px; +} + +//--------------------------------------------------------------------- +// Subheader on (pretty much) every page + +.subhead { + margin-bottom:22px; + background:#f4f4f4; + border:none; + position: relative; + // DO I NEED THIS? + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} + +.subhead h1 { + font-family: 'Open Sans','Helvetica Neue', Helvetica, Arial, sans-serif; + font-size:26px; + font-weight:normal; + color:#777; + line-height: 55px; + margin-top: 10px; + margin-bottom: 10px; + span { + padding-right:10px; + } + small { + color:#999; + font-size:60%; + } +} + +.subhead h1.user { + span { + padding-left:10px; + font-size: 24px; + line-height: 24px; + } + img { + width:42px; + height:42px; + border-radius:50%; + -webkit-border-radius:50%; + -moz-border-radius:50%; + } + small { + color:#777; + font-size:60%; + } + } + +.subhead .container { + position:relative; + padding:0px; +} + +// Give the tabs something to sit on +.subhead .nav-tabs { + //border-bottom: 1px solid #dee3e6; + position:absolute; + right:0px; + bottom:0px; + margin:0px; + padding:0px; + border-bottom:none; +} +// Make the list-items overlay the bottom border +.subhead .nav-tabs > li { + margin-bottom: 0px; +} +// Actual tabs (as links) +// Actual tabs (as links) +.subhead .nav-tabs > li > a { + color:#999; + padding:10px 20px; + line-height:20px; + border:0px; +} + +.subhead .nav-tabs > li.active > a{ + color:#777; + border:0px; +} + +// +// nav-repos +// -------------------------------------------- + +.nav-repos, +.nav-branches { + margin-top: 0px; + //margin-top:20px; + border:none; + li { + a { + line-height:22px; + + margin-bottom: 0px; + padding: 5px 15px; + padding:10px 15px; + border:none; + margin:0px !IMPORTANT; + font-size:15px; + border-radius:0px; + -moz-border-radius:0px; + -webkit-border-radius:0px; + overflow:hidden; + white-space:nowrap; + text-overflow:ellipsis; + span { + color:#777; + } + span [class^="icon-"] { + color:#999; + } + i { + padding-right:10px; + color:#AAA; + font-size:18px; + vertical-align: middle; + } + } + } + li.active { + a { + background: #f4f4f4; + } + a:hover { + background: #f4f4f4; + } + } +} + +.nav-repos .nav-header, +.nav-branches .nav-header { + padding: 15px 15px; + margin-bottom: 0px; + color: #666; + font-size: 24px; + text-transform:lowercase; + font-family: 'Open Sans','Helvetica Neue',Helvetica,Arial,sans-serif; + font-weight: normal; + display:none; +} + +.nav-repos, +.nav-branches { + li { + a { + //border-top: 1px solid #EEE; + padding: 12px 15px; + margin-bottom:0px; + color:#777; + } + } +} + +.nav-pills.nav-repos > li, +.nav-repos > li { + &> a { + padding:13px 15px; + } + &:nth-child(odd) { + background: #f7f7f7; + } +} +.nav-repos > li > a span { + color:#AAA; + &:last-child { + color:#555; + } +} + +.nav-pills.nav-branches > li > a { + padding:10px 15px; +} + +// ALERTS + +.alert { + border: none; + border-radius: 0px; + background: #f4f4f4; + color: #999; + padding: 30px; + font-size: 18px; + margin-bottom: 20px; + .pull-right { + margin-top:-10px; + } +} + +.alert .thumbnails { + margin:0px; + padding:0px; + li { + margin:0px; + padding:0px; + margin-left:20px; + margin-top:20px; + } + + .thumbnail { + background:transparent; + } +} + +.alert.alert-feed { + margin-top: 0px; + margin-bottom: 0px; + padding:15px 20px; + + span { + line-height:50px; + } + + span.label { + float:right; + color: #BBB; + font-size: 16px; + padding-right:10px; + font-weight:normal; + } + + .thumbnails > li { + margin:0px; + margin-left:5px; + padding:0px; + display:inline-block; + a { + border: 0px; + padding: 0px; + img { + border-radius:50%; + -moz-border-radius:50%; + -webkit-border-radius:50%; + width: 32px; + height: 32px; + margin-top:10px; + } + } + } +} + +.alert.alert-success { + color: #3c763d; + background-color: #dff0d8; + + text-shadow: none; + font-size: 16px; + margin-top: 30px; + margin-bottom: 10px; +} + +.alert.alert-error { + background: #ebccd1; + color: #a94442; + + text-shadow: none; + font-size: 16px; + margin-top: 30px; + margin-bottom: 10px; +} + +// +// build list +// -------------------------------------------- + +.commit-list { + list-style: none; + margin:0px; + padding-left:0px; +} + + + +.commit-list > li { + margin:0px; + position:relative; + padding:25px 20px; + padding-left:25px; + list-style:none; + + &> img { + position: absolute; + right: 20px; + top: 20px; + -webkit-border-radius: 50%; + -moz-border-radius: 50%; + border-radius: 50%; + width: 48px; + height: 48px; + background: #EEE; + } +} + +.commit-list > li:last-child { + border-bottom:none; +} + +.commit-list > li:nth-child(even) { + background:#f7f7f7; +} + +.commit-list.commit-list-alt > li:nth-child(even) { + background:#fff; +} + +.commit-list.commit-list-alt > li:nth-child(odd) { + background:#f7f7f7; +} + +.commit-list > li > h3 { + display:inline; + line-height:18px; + height:18px; + font-weight:normal; + color: #777; + font-size: 18px; + margin-top:10px; + a, span { + margin-top:2px; + display:inline-block; + } + a { + color:#555; + text-decoration:none; + &:hover { + text-decoration:underline; + } + } + small { + font-size:14px; + display:inline-block; + margin-left:5px; + white-space:nowrap; + } + p { + font-size:14px; + color:#999; + margin:0px; + padding:0px; + padding-top:2px; + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 350px; + } +} + + +// BUTTONS + +.btn.btn-Success, +.btn.btn-Failure, +.btn.btn-Pending, +.btn.btn-Started, +.btn.btn-Error, +.btn.btn-None { + + border: none; + background: #BBB; + color: #FFF; + font-weight: normal; + text-shadow: none; + box-shadow: none; + padding: 10px 15px; + font-size: 16px; + min-width:75px; + float:left; + margin-right:25px; + font-size:16px; + text-transform:none; + font-weight:normal; + + padding: 0px; + border-bottom: 0px; + width: 48px; + height: 48px; + min-height: 48px; + max-height: 48px; + min-width: 48px; + max-width: 48px; + border-radius:50%; + -webkit-border-radius:50%; + -moz-border-radius:50%; +} + +.btn.btn-success, +.btn.btn-Success { + background:rgba(81, 163, 81, 0.75); +} +.btn.btn-failure, +.btn.btn-Failure, +.btn.btn-Error { + background:rgba(189, 54, 47, 0.8); +} + +.btn.btn-Scheduled, +.btn.btn-Pending, +.btn.btn-Started { + background: #D5E802; +} + +.btn.btn-Success:before { + content: "\f00c"; + font-family: 'FontAwesome'; + font-size: 22px; + line-height: 48px; + opacity:0.8; + color:#FFF; +} +.btn.btn-Error:before, +.btn.btn-Failure:before { + content: "\f00d"; + font-family: 'FontAwesome'; + font-size: 22px; + line-height: 48px; + opacity:0.8; + color:#fff; +} + +.btn.btn-refresh { + position: absolute; + left: -95px; + float: left; + background: #f7f7f7; + width: 75px; + border-radius: 0px; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + text-align: center; + font-size: 22px; + text-decoration: none; + color: #999; + padding:10px 0px; + z-index:1; + span { + display:block; + font-size:14px; + padding-top:5px; + } +} + +.btn.btn-Started:before, +.btn.btn-Scheduled:before, +.btn.btn-Pending:before { + content: "\f021"; + font-family: 'FontAwesome'; + font-size: 22px; + line-height: 48px; + color:#FFF; + + display: inline-block; + + -webkit-animation: spin 1.5s infinite linear; + -moz-animation: spin 1.5s infinite linear; + -ms-animation: spin 1.5s infinite linear; + -o-animation: spin 1.5s infinite linear; + animation: spin 1.5s infinite linear; +} + +.btn.btn-mini { + width:24px; + height:24px; + max-width:24px; + max-height:24px; + min-width:24px; + min-height:24px; +} + +.btn.btn-mini.btn-Success:before, +.btn.btn-mini.btn-Failure:before, +.btn.btn-mini.btn-Error:before, +.btn.btn-mini.btn-Started:before, +.btn.btn-mini.btn-Scheduled:before, +.btn.btn-mini.btn-Pending:before { + line-height:24px !IMPORTANT; + font-size:14px !IMPORTANT; +} + +@-webkit-keyframes spin { + to { -webkit-transform: rotate(360deg); } +} + +@-moz-keyframes spin { + to { -moz-transform: rotate(360deg); } +} + +@-ms-keyframes spin { + to { -ms-transform: rotate(360deg); } +} + +@-o-keyframes spin { + to { -o-transform: rotate(360deg); } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + + +.btn.btn-None { + //background: #ebebeb; + //border-bottom: 2px solid #CCC; + //color: #737373; + background:rgba(81, 163, 81, 0.75); + border-bottom:2px solid rgba(81, 163, 81, 1); +} + + +.btn.btn-default { + padding:7px 20px; + padding-bottom:6px; + border-bottom-width: 2px; + color:#777; +} + +.btn.btn-primary, { + background:rgba(251, 136, 35, 0.85); + padding:7px 20px; + border:none; + border-bottom: 2px solid #cd6d1d; + &:hover, &:active { + background:rgba(251, 136, 35, 1); + border-bottom: 2px solid #cd6d1d; + } +} + +.btn.btn-danger { + border:none; + padding:7px 20px; + background: rgba(189, 54, 47, 0.8); + border-bottom: 2px solid #bd362f; + &:hover, &:active { + background:rgba(189, 54, 47, 0.9); + border-bottom: 2px solid #bd362f; + } +} + +/////////////////////////////////////////////////////// +/////////////////////////////////////////////////////// +// NAV PILLS + +.nav-pills > li { + //margin-top: 2px; + a { + padding:7px 15px; + color: rgb(119, 119, 119); + } +} + + +.nav-pills > li > a, +.nav-pills > li > a:hover, +.nav-pills > li > a:focus{ + border-radius:0px; + -webkit-border-radius:0px; + -moz-border-radius:0px; + //margin-top:0px; + //padding:7px 15px; +} + +.nav-pills > .active { + border-radius:0px; + -webkit-border-radius:0px; + -moz-border-radius:0px; + //margin-top:0px; +} + +.nav-pills>li.active>a, +.nav-pills>li.active>a:hover, +.nav-pills>li.active>a:focus, +.nav-pills > .active > a, +.nav-pills > .active > a:hover, +.nav-pills > .active > a:focus{ + border-radius:0px; + -webkit-border-radius:0px; + -moz-border-radius:0px; + //margin-bottom:0px; + margin-top:0px; + background-color: #f4f4f4; + color: rgb(119, 119, 119); + +} + +////////////////////////////////////// +// FORM + +form label { + color: #777; + margin-top: 25px; + margin-bottom: 7px; + margin-left: 2px; + cursor:default; + font-weight:normal; + display:block; + + &:first-child { + margin-top:0px; + } +} + +form .form-actions { + background:transparent; + padding:30px 0px; + margin:0px; + border:none; + + .btn { + margin-right:3px; + } +} + +form .form-group { + margin-bottom:30px; +} + +input.form-control, +select.form-control { + display: inline-block; + height: 30px; + padding: 4px 6px; + margin-bottom: 0px; + font-size: 14px; + line-height: 20px; + color: #555555; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + vertical-align: middle; + + width:270px; +} + +input.form-control-small, +select.form-control-small { + width: 90px; +} + +input.form-control-large, +select.form-control-large { + width: 210px; +} + +input.form-control-xlarge, +select.form-control-xlarge { + width: 270px; +} + +span.form-control { + color: #999999; + background-color: #fcfcfc; + border-color: #cccccc; + -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); + -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); + cursor: not-allowed; +} + +textarea { + font-family: 'Droid Sans Mono', monospace; +} + +/////////////////////////////// + +//////////////////////////////// + +.avatar-link { + float:right; + position:relative; + background:#eee; + padding:5px; + text-decoration:none; + span { + display: block; + text-align: center; + color: #777; + padding: 5px; + } + img { + width:160px; + height:160px; + max-width:160px; + max-height:160px; + min-width:160px; + min-height:160px; + } + &:hover { + text-decoration:none; + &:before { + color: #fff; + text-shadow: 1px 1px rgba(0,0,0,0.5); + font-family:'FontAwesome'; + content:"\f044"; + position:absolute; + right:10px; + top:10px; + font-size:16px; + } + } +} + +/////////////////////////// + +.row.gravatar-list { + margin-left:0px; + .col-xs-3 { + width:23.404255319148934%; + margin:0px; + padding:0px; + margin-right:2.127659574468085%; + &:nth-child(4) { + margin-right:0px; + } + a.thumbnail, a.thumbnail:hover { + border-radius:0px; + -webkit-border-radius:0px; + -moz-border-radius:0px; + box-shadow:none; + border:none; + text-decoration:none; + background:#f4f4f4; + img { + width:156px; + height:156px; + } + .caption > h3 { + font-weight:normal; + text-align:center; + font-size:15px; + padding: 0px; + line-height: 16px; + margin: 0px; + max-width: 156px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color:rgb(85, 85, 85); + } + &.team-add { + &> span { + min-width:156px; + min-height:156px; + display:inline-block; + text-align: center; + vertical-align: middle; + line-height: 156px; + font-size: 64px; + color: #d7d7d7; + } + .caption > h3 { + color: #999; + } + } + } + } +} + + + + + + + +//////////////////////////////////////// +// SWITCH + +/* Checkbox hack */ +.switch input, +.switch a { + cursor: pointer; + height: 100%; + opacity: 0; + position: absolute; + width: 100%; + z-index: 100; +} + +/* Container */ +.switch { + height: 40px; + position: relative; + width: 100px; +} + +/* Background unchecked */ +.switch label { + background: rgba(189, 54, 47, 0.8); + border-radius: 2px; + display: block; + height: 100%; + position: relative; + transition: all .15s ease; + width: 100%; + border-radius:5px; + color:#FFF; +} + +/* Slider unchecked */ +.switch label div { + background: #fff; + border-radius: 2px; + display: block; + height: 28px; + left: 6px; + position: absolute; + top: 6px; + transition: all .15s ease; + width: 35px; + z-index: 3; + border-radius:5px; +} + +/* Vertical lines on slider */ +.switch label div:before { + bottom: 0; + content: ''; + display: block; + height: 15px; + left: 0; + margin: auto; + position: absolute; + right: 0; + top: 0; + transition: all .15s ease; + width: 3px; + //box-shadow: -6px 0 0 0 #d3d3d3, 6px 0 0 0 #d3d3d3; +} + +/* Icon styles */ +.switch .fontawesome-ok { + font-size: 18px; + left: 15px; + position: relative; + top: 8px; + z-index: 2; +} + +.switch .fontawesome-ok:before { + font-family: 'FontAwesome'; + content:"\f00c"; + opacity:0.8; +} + + +.switch .fontawesome-remove { + font-size: 18px; + left: 45px; + position: relative; + top: 8px; + z-index: 2; +} + +.switch .fontawesome-remove:before { + font-family: 'FontAwesome'; + content:"\f00d"; + opacity:0.8; +} + +/* Checked States */ +.switch input:checked~label { + background: rgba(81, 163, 81, 0.75); +} + +.switch input:checked~label div { + left: 58px; +} + + +/////// BUID OUTPUT + +pre { + white-space: pre-wrap; + background: #464849; + color: #FFF; + padding: 20px 20px; + border-radius: 5px; + font-size: 12px; + margin-top: 0px; + overflow: hidden; + font-family:'Droid Sans Mono', 'monospace'; + line-height: 20px + +} + +.alert.alert-build-Success, +.alert.alert-build-Error, +.alert.alert-build-Failure, +.alert.alert-build-Pending, +.alert.alert-build-Started { + text-shadow:none; + margin-top:5px; + margin-bottom:30px; + font-size:15px; + padding:15px 20px; + border-radius:5px; + -webkit-border-radius:5px; + -moz-border-radius:5px; + + span { + line-height:32px; + span { + text-decoration:underline; + } + } + + a.btn { + width:32px; + height:32px; + max-width:32px; + max-height:32px; + min-width:32px; + min-height:32px; + margin-right:20px !IMPORTANT; + &:before { + font-size: 22px !IMPORTANT; + line-height: 32px !IMPORTANT; + } + } +} + +.build-details { + + dt { + float:left; + width:90px; + color:#333; + font-weight:normal; + &:after { + content:':'; + } + } + dd { + color:#555; + white-space:nowrap; + } + + .build-summary { + float:left; + width:250px; + padding-left:20px; + } + + img { + float:left; + border-radius:50%; + -webkit-border-radius:50%; + -moz-border-radius:50%; + margin-right: 30px; + width: 58px; + height: 58px; + } + + .commit-summary { + float:left; + } + + background:#FFF; + margin-bottom:40px; + + + +} + +.build-details.affix { + top: 0px; + padding-top:15px; + padding-bottom:30px; +} +.build-details.affix-top { +} + +.alert.alert-build-Success { + color: #468847; + background-color: #dff0d8; + border-color: #d6e9c6; +} + +.alert.alert-build-Error, +.alert.alert-build-Failure { + background-color:#f2dede; + color:#b94a48; +} + +.alert.alert-build-Pending, +.alert.alert-build-Started { + color: #c09853; + background: rgba(213, 232, 2, 0.2); + background-color: rgba(213, 232, 2, 0.2); +} + + + + + + +// new repo form + +.form-repo { + .field-group { + display:inline-block; + margin-bottom:30px; + label { + margin-top:0px; + } + } + .field-separator { + display:inline-block; + font-size:20px; + } + + ul { + padding-top:15px; + margin-bottom:0px; + padding-bottom:0px; + list-style: none; + padding-left:0px; + margin-left:0px; + li { + padding-bottom:10px; + img { + width:32px; + height:32px; + border-radius:50%; + -webkit-border-radius:50%; + -moz-border-radius:50%; + margin-left:10px; + margin-right:10px; + } + } + } +} + + ul.account-radio-group { + padding-top:15px; + margin-bottom:0px; + padding-bottom:0px; + list-style: none; + padding-left:0px; + margin-left:0px; + li { + padding-bottom:10px; + img { + width:32px; + height:32px; + border-radius:50%; + -webkit-border-radius:50%; + -moz-border-radius:50%; + margin-left:10px; + margin-right:10px; + } + } + } + + + +// LOGIN FORM + +.form-centered { + background:#FFF; + overflow:hidden; + max-width: 350px; + margin: 0 auto; + text-align: center; + margin-top: 40px; + padding: 0px 0px 20px 0px; + border-radius:5px; + -webkit-border-radius:5px; + -moz-border-radius:5px; + + h1 { + background-color: #262829; + color: #FFF; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + font-family: 'Orbitron'; + font-weight: normal; + font-size: 18px; + padding: 25px 10px; + margin-top:0px; + margin-bottom:30px; + } + + input[type="submit"] { + + border:none; + color: #FFF; + padding: 10px; + font-size: 18px; + margin-top: 30px; + margin-bottom: 10px; + width: 280px; + background: #AAA; + border-radius:5px; + -webkit-border-radius:5px; + -moz-border-radius:5px; + } + + input[type="text"], + input[type="password"] { + margin-bottom: 0px; + font-size: 16px; + padding: 10px; + width: 280px; + height:40px; + &:last-child { + border-top-width: 0px; + border-top-right-radius: 0px; + border-top-left-radius: 0px; + } + &:first-child { + border-bottom-right-radius: 0px; + border-bottom-left-radius: 0px; + } + &[disabled] { + box-shadow: none; + background: #f7f7f7; + color: #999; + text-shadow: none; + } + &.only-child { + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + border-top-right-radius: 4px; + border-top-left-radius: 4px; + border-bottom-width: 1px; + border-top-width: 1px; + } + } + + a, a:visited { + color:#999; + text-decoration:underline; + } + + h1~div.alert { + margin-top:-30px; + } + + .alert.alert-danger { + background: #ebccd1; + color: #a94442; + text-shadow: none; + font-weight: normal; + font-size: 16px; + padding: 20px; + margin-bottom:30px; + } + + .alert.alert-success { + color: #3c763d; + background-color: #dff0d8; + text-shadow: none; + font-weight: normal; + font-size: 16px; + padding: 20px; + margin-bottom:30px; + } +} \ No newline at end of file diff --git a/cmd/droned/assets/img/build_failing.png b/cmd/droned/assets/img/build_failing.png new file mode 100644 index 000000000..ed9087edd Binary files /dev/null and b/cmd/droned/assets/img/build_failing.png differ diff --git a/cmd/droned/assets/img/build_none.png b/cmd/droned/assets/img/build_none.png new file mode 100644 index 000000000..f4f431d1c Binary files /dev/null and b/cmd/droned/assets/img/build_none.png differ diff --git a/cmd/droned/assets/img/build_success.png b/cmd/droned/assets/img/build_success.png new file mode 100644 index 000000000..2d1cc7585 Binary files /dev/null and b/cmd/droned/assets/img/build_success.png differ diff --git a/cmd/droned/assets/img/build_unknown.png b/cmd/droned/assets/img/build_unknown.png new file mode 100644 index 000000000..6f3e3e871 Binary files /dev/null and b/cmd/droned/assets/img/build_unknown.png differ diff --git a/cmd/droned/assets/img/favicon.ico b/cmd/droned/assets/img/favicon.ico new file mode 100644 index 000000000..81b66798a Binary files /dev/null and b/cmd/droned/assets/img/favicon.ico differ diff --git a/cmd/droned/assets/img/favicon.png b/cmd/droned/assets/img/favicon.png new file mode 100644 index 000000000..661fa5ef8 Binary files /dev/null and b/cmd/droned/assets/img/favicon.png differ diff --git a/cmd/droned/drone.go b/cmd/droned/drone.go new file mode 100644 index 000000000..6397d134e --- /dev/null +++ b/cmd/droned/drone.go @@ -0,0 +1,187 @@ +package main + +import ( + "database/sql" + "flag" + "log" + "net/http" + "strings" + + "code.google.com/p/go.net/websocket" + "github.com/GeertJohan/go.rice" + "github.com/bmizerany/pat" + _ "github.com/mattn/go-sqlite3" + "github.com/russross/meddler" + + "github.com/drone/drone/pkg/channel" + "github.com/drone/drone/pkg/database" + "github.com/drone/drone/pkg/handler" +) + +var ( + // local path where the SQLite database + // should be stored. By default this is + // in the current working directory. + path string + + // port the server will run on + port string + + // database driver used to connect to the database + driver string + + // driver specific connection information. In this + // case, it should be the location of the SQLite file + datasource string +) + +func main() { + // parse command line flags + flag.StringVar(&path, "path", "", "") + flag.StringVar(&port, "port", ":8080", "") + flag.StringVar(&driver, "driver", "sqlite3", "") + flag.StringVar(&datasource, "datasource", "drone.sqlite", "") + flag.Parse() + + // setup database and handlers + setupDatabase() + setupStatic() + setupHandlers() + + // start the webserver on the default port. + panic(http.ListenAndServe(port, nil)) +} + +// setup the database connection and register with the +// global database package. +func setupDatabase() { + // inform meddler we're using sqlite + meddler.Default = meddler.SQLite + + // connect to the SQLite database + db, err := sql.Open(driver, datasource) + if err != nil { + log.Fatal(err) + } + + database.Set(db) +} + +// setup routes for static assets. These assets may +// be directly embedded inside the application using +// the `rice embed` command, else they are served from disk. +func setupStatic() { + box := rice.MustFindBox("assets") + http.Handle("/css/", http.FileServer(box.HTTPBox())) + http.Handle("/img/", http.FileServer(box.HTTPBox())) +} + +// setup routes for serving dynamic content. +func setupHandlers() { + m := pat.New() + m.Get("/login", handler.ErrorHandler(handler.Login)) + m.Post("/login", handler.ErrorHandler(handler.Authorize)) + m.Get("/logout", handler.ErrorHandler(handler.Logout)) + m.Get("/forgot", handler.ErrorHandler(handler.Forgot)) + m.Post("/forgot", handler.ErrorHandler(handler.ForgotPost)) + m.Get("/reset", handler.ErrorHandler(handler.Reset)) + m.Post("/reset", handler.ErrorHandler(handler.ResetPost)) + m.Get("/register", handler.ErrorHandler(handler.Register)) + m.Post("/register", handler.ErrorHandler(handler.RegisterPost)) + m.Get("/accept", handler.UserHandler(handler.TeamMemberAccept)) + + // handlers for setting up your GitHub repository + m.Post("/new/github.com", handler.UserHandler(handler.RepoCreateGithub)) + m.Get("/new/github.com", handler.UserHandler(handler.RepoAdd)) + + // handlers for linking your GitHub account + m.Get("/auth/login/github", handler.UserHandler(handler.LinkGithub)) + + // handlers for dashboard pages + m.Get("/dashboard/team/:team", handler.UserHandler(handler.TeamShow)) + m.Get("/dashboard", handler.UserHandler(handler.UserShow)) + + // handlers for user account management + m.Get("/account/user/profile", handler.UserHandler(handler.UserEdit)) + m.Post("/account/user/profile", handler.UserHandler(handler.UserUpdate)) + m.Get("/account/user/delete", handler.UserHandler(handler.UserDeleteConfirm)) + m.Post("/account/user/delete", handler.UserHandler(handler.UserDelete)) + m.Get("/account/user/password", handler.UserHandler(handler.UserPass)) + m.Post("/account/user/password", handler.UserHandler(handler.UserPassUpdate)) + m.Get("/account/user/teams/add", handler.UserHandler(handler.TeamAdd)) + m.Post("/account/user/teams/add", handler.UserHandler(handler.TeamCreate)) + m.Get("/account/user/teams", handler.UserHandler(handler.UserTeams)) + + // handlers for team managements + m.Get("/account/team/:team/profile", handler.UserHandler(handler.TeamEdit)) + m.Post("/account/team/:team/profile", handler.UserHandler(handler.TeamUpdate)) + m.Get("/account/team/:team/delete", handler.UserHandler(handler.TeamDeleteConfirm)) + m.Post("/account/team/:team/delete", handler.UserHandler(handler.TeamDelete)) + m.Get("/account/team/:team/members/add", handler.UserHandler(handler.TeamMemberAdd)) + m.Post("/account/team/:team/members/add", handler.UserHandler(handler.TeamMemberInvite)) + m.Get("/account/team/:team/members/edit", handler.UserHandler(handler.TeamMemberEdit)) + m.Post("/account/team/:team/members/edit", handler.UserHandler(handler.TeamMemberUpdate)) + m.Post("/account/team/:team/members/delete", handler.UserHandler(handler.TeamMemberDelete)) + m.Get("/account/team/:team/members", handler.UserHandler(handler.TeamMembers)) + + // handlers for system administration + m.Get("/account/admin/settings", handler.AdminHandler(handler.AdminSettings)) + m.Post("/account/admin/settings", handler.AdminHandler(handler.AdminSettingsUpdate)) + m.Get("/account/admin/users/edit", handler.AdminHandler(handler.AdminUserEdit)) + m.Post("/account/admin/users/edit", handler.AdminHandler(handler.AdminUserUpdate)) + m.Post("/account/admin/users/delete", handler.AdminHandler(handler.AdminUserDelete)) + m.Get("/account/admin/users/add", handler.AdminHandler(handler.AdminUserAdd)) + m.Post("/account/admin/users", handler.AdminHandler(handler.AdminUserInvite)) + m.Get("/account/admin/users", handler.AdminHandler(handler.AdminUserList)) + + // handlers for GitHub post-commit hooks + m.Post("/hook/github.com", handler.ErrorHandler(handler.Hook)) + + // handlers for first-time installation + m.Get("/install", handler.ErrorHandler(handler.Install)) + m.Post("/install", handler.ErrorHandler(handler.InstallPost)) + + // handlers for repository, commits and build details + 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/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)) + m.Get("/:host/:owner/:name/badges", handler.RepoAdminHandler(handler.RepoBadges)) + m.Get("/:host/:owner/:name/keys", handler.RepoAdminHandler(handler.RepoKeys)) + m.Get("/:host/:owner/:name/delete", handler.RepoAdminHandler(handler.RepoDeleteForm)) + m.Post("/:host/:owner/:name/delete", handler.RepoAdminHandler(handler.RepoDelete)) + m.Get("/:host/:owner/:name", handler.RepoHandler(handler.RepoDashboard)) + m.Post("/:host/:owner/:name", handler.RepoHandler(handler.RepoUpdate)) + http.Handle("/feed", websocket.Handler(channel.Read)) + + // no routes are served at the root URL. Instead we will + // redirect the user to his/her dashboard page. + m.Get("/", http.RedirectHandler("/dashboard", http.StatusSeeOther)) + + // 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. + if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") { + http.Redirect(w, r, r.URL.Path[:len(r.URL.Path)-1], http.StatusSeeOther) + return + } + + // standard header variables that should be set, for good measure. + w.Header().Add("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate") + w.Header().Add("X-Frame-Options", "DENY") + w.Header().Add("X-Content-Type-Options", "nosniff") + w.Header().Add("X-XSS-Protection", "1; mode=block") + + // ok, now we're ready to serve the request. + m.ServeHTTP(w, r) + }) +} diff --git a/deb/drone/DEBIAN/control b/deb/drone/DEBIAN/control new file mode 100644 index 000000000..a7d76e06b --- /dev/null +++ b/deb/drone/DEBIAN/control @@ -0,0 +1,7 @@ +Package: drone +Version: 0.1 +Section: base +Priority: optional +Architecture: amd64 +Maintainer: Brad Rydzewski +Description: Drone continuous integration server diff --git a/deb/drone/etc/init/drone.conf b/deb/drone/etc/init/drone.conf new file mode 100644 index 000000000..102b2bc34 --- /dev/null +++ b/deb/drone/etc/init/drone.conf @@ -0,0 +1,8 @@ +start on (filesystem and net-device-up) + +chdir /var/lib/drone +console log + +script + droned --port=":80" +end script \ No newline at end of file diff --git a/pkg/build/build.go b/pkg/build/build.go new file mode 100644 index 000000000..3fb5aecd9 --- /dev/null +++ b/pkg/build/build.go @@ -0,0 +1,471 @@ +package build + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/drone/drone/pkg/build/buildfile" + "github.com/drone/drone/pkg/build/docker" + "github.com/drone/drone/pkg/build/dockerfile" + "github.com/drone/drone/pkg/build/log" + "github.com/drone/drone/pkg/build/proxy" + "github.com/drone/drone/pkg/build/repo" + "github.com/drone/drone/pkg/build/script" +) + +// instance of the Docker client +var client = docker.New() + +// BuildState stores information about a build +// process including the Exit status and various +// Runtime statistics (coming soon). +type BuildState struct { + Started int64 + Finished int64 + ExitCode int + + // we may eventually include detailed resource + // usage statistics, including including CPU time, + // Max RAM, Max Swap, Disk space, and more. +} + +// Builder represents a build process being prepared +// to run. +type Builder struct { + // Image specifies the Docker Image that will be + // used to virtualize the Build process. + Build *script.Build + + // Source specifies the Repository path of the code + // that we are testing. + // + // The source repository may be a local repository + // on the current filesystem, or a remote repository + // on GitHub, Bitbucket, etc. + Repo *repo.Repo + + // Key is an identify file, such as an RSA private key, that + // will be copied into the environments ~/.ssh/id_rsa file. + Key []byte + + // Timeout is the maximum amount of to will wait for a process + // to exit. + // + // The default is no timeout. + Timeout time.Duration + + // Stdout specifies the builds's standard output. + // + // If stdout is nil, Run connects the corresponding file descriptor + // to the null device (os.DevNull). + Stdout io.Writer + + // BuildState contains information about an exited build, + // available after a call to Run. + BuildState *BuildState + + // Docker image that was created for + // this build. + image *docker.Image + + // Docker container was that created + // for this build. + container *docker.Run + + // Docker containers created for the + // specified services and linked to + // this build. + services []*docker.Container +} + +func (b *Builder) Run() error { + // teardown will remove the Image and stop and + // remove the service containers after the + // build is done running. + defer b.teardown() + + // setup will create the Image and supporting + // service containers. + if err := b.setup(); err != nil { + return err + } + + // make sure build state is not nil + b.BuildState = &BuildState{} + b.BuildState.ExitCode = 0 + b.BuildState.Started = time.Now().UTC().Unix() + + c := make(chan error, 1) + go func() { + c <- b.run() + }() + + // wait for either a) the job to complete or b) the job to timeout + select { + case err := <-c: + return err + case <-time.After(b.Timeout): + log.Errf("time limit exceeded for build %s", b.Build.Name) + b.BuildState.ExitCode = 124 + b.BuildState.Finished = time.Now().UTC().Unix() + return nil + } + + return nil +} + +func (b *Builder) setup() error { + + // temp directory to store all files required + // to generate the Docker image. + dir, err := ioutil.TempDir("", "drone-") + if err != nil { + return err + } + + // clean up after our mess. + defer os.RemoveAll(dir) + + // make sure the image isn't empty. this would be bad + if len(b.Build.Image) == 0 { + log.Err("Fatal Error, No Docker Image specified") + return fmt.Errorf("Error: missing Docker image") + } + + // if we're using an alias for the build name we + // should substitute it now + if alias, ok := builders[b.Build.Image]; ok { + b.Build.Image = alias.Tag + } + + // if this is a local repository we should symlink + // to the source code in our temp directory + if b.Repo.IsLocal() { + // this is where we used to use symlinks. We should + // talk to the docker team about this, since copying + // the entire repository is slow :( + // + // see https://github.com/dotcloud/docker/pull/3567 + + //src := filepath.Join(dir, "src") + //err = os.Symlink(b.Repo.Path, src) + //if err != nil { + // return err + //} + + src := filepath.Join(dir, "src") + cmd := exec.Command("cp", "-a", b.Repo.Path, src) + if err := cmd.Run(); err != nil { + return err + } + } + + // start all services required for the build + // that will get linked to the container. + for _, service := range b.Build.Services { + image, ok := services[service] + if !ok { + return fmt.Errorf("Error: Invalid or unknown service %s", service) + } + + // debugging + log.Infof("starting service container %s", image.Tag) + + // Run the contianer + run, err := client.Containers.RunDaemonPorts(image.Tag, image.Ports...) + if err != nil { + return err + } + + // Get the container info + info, err := client.Containers.Inspect(run.ID) + if err != nil { + // on error kill the container since it hasn't yet been + // added to the array and would therefore not get + // removed in the defer statement. + client.Containers.Stop(run.ID, 10) + client.Containers.Remove(run.ID) + return err + } + + // Add the running service to the list + b.services = append(b.services, info) + + } + + if err := b.writeIdentifyFile(dir); err != nil { + return err + } + + if err := b.writeBuildScript(dir); err != nil { + return err + } + + if err := b.writeProxyScript(dir); err != nil { + return err + } + + if err := b.writeDockerfile(dir); err != nil { + return err + } + + // debugging + log.Info("creating build image") + + // check for build container (ie bradrydzewski/go:1.2) + // and download if it doesn't already exist + if _, err := client.Images.Inspect(b.Build.Image); err == docker.ErrNotFound { + // download the image if it doesn't exist + if err := client.Images.Pull(b.Build.Image); err != nil { + return err + } + } + + // create the Docker image + id := createUID() + if err := client.Images.Build(id, dir); err != nil { + return err + } + + // debugging + log.Infof("copying repository to %s", b.Repo.Dir) + + // get the image details + b.image, err = client.Images.Inspect(id) + if err != nil { + // if we have problems with the image make sure + // we remove it before we exit + client.Images.Remove(id) + return err + } + + return nil +} + +// teardown is a helper function that we can use to +// stop and remove the build container, its supporting image, +// and the supporting service containers. +func (b *Builder) teardown() error { + + // stop and destroy the container + if b.container != nil { + + // debugging + log.Info("removing build container") + + // stop the container, ignore error message + client.Containers.Stop(b.container.ID, 15) + + // remove the container, ignore error message + if err := client.Containers.Remove(b.container.ID); err != nil { + log.Errf("failed to delete build container %s", b.container.ID) + } + } + + // stop and destroy the container services + for i, container := range b.services { + // debugging + log.Infof("removing service container %s", b.Build.Services[i]) + + // stop the service container, ignore the error + client.Containers.Stop(container.ID, 15) + + // remove the service container, ignore the error + if err := client.Containers.Remove(container.ID); err != nil { + log.Errf("failed to delete service container %s", container.ID) + } + } + + // destroy the underlying image + if b.image != nil { + // debugging + log.Info("removing build image") + + if _, err := client.Images.Remove(b.image.ID); err != nil { + log.Errf("failed to completely delete build image %s. %s", b.image.ID, err.Error()) + } + } + + return nil +} + +func (b *Builder) run() error { + // create and run the container + conf := docker.Config{ + Image: b.image.ID, + AttachStdin: false, + AttachStdout: true, + AttachStderr: true, + } + host := docker.HostConfig{ + Privileged: false, + } + + // debugging + log.Noticef("starting build %s", b.Build.Name) + + // link service containers + for i, service := range b.services { + image, ok := services[b.Build.Services[i]] + if !ok { + continue // THIS SHOULD NEVER HAPPEN + } + // link the service container to our + // build container. + host.Links = append(host.Links, service.Name[1:]+":"+image.Name) + } + + // create the container from the image + run, err := client.Containers.Create(&conf) + if err != nil { + return err + } + + // cache instance of docker.Run + b.container = run + + // attach to the container + go func() { + client.Containers.Attach(run.ID, &writer{b.Stdout}) + }() + + // start the container + if err := client.Containers.Start(run.ID, &host); err != nil { + b.BuildState.ExitCode = 1 + b.BuildState.Finished = time.Now().UTC().Unix() + return err + } + + // wait for the container to stop + wait, err := client.Containers.Wait(run.ID) + if err != nil { + b.BuildState.ExitCode = 1 + b.BuildState.Finished = time.Now().UTC().Unix() + return err + } + + // set completion time + b.BuildState.Finished = time.Now().UTC().Unix() + + // get the exit code if possible + b.BuildState.ExitCode = wait.StatusCode //b.container.State.ExitCode + + return nil +} + +// writeDockerfile is a helper function that generates a +// Dockerfile and writes to the builds temporary directory +// so that it can be used to create the Image. +func (b *Builder) writeDockerfile(dir string) error { + var dockerfile = dockerfile.New(b.Build.Image) + dockerfile.WriteWorkdir(b.Repo.Dir) + dockerfile.WriteAdd("drone", "/usr/local/bin/") + + // upload source code if repository is stored + // on the host machine + if b.Repo.IsRemote() == false { + dockerfile.WriteAdd("src", filepath.Join(b.Repo.Dir)) + } + + switch { + case strings.HasPrefix(b.Build.Image, "bradrydzewski/"), + strings.HasPrefix(b.Build.Image, "drone/"): + // the default user for all official Drone imnage + // is the "ubuntu" user, since all build images + // inherit from the ubuntu cloud ISO + dockerfile.WriteUser("ubuntu") + dockerfile.WriteEnv("HOME", "/home/ubuntu") + dockerfile.WriteEnv("LANG", "en_US.UTF-8") + dockerfile.WriteEnv("LANGUAGE", "en_US:en") + dockerfile.WriteEnv("LOGNAME", "ubuntu") + dockerfile.WriteEnv("TERM", "xterm") + dockerfile.WriteEnv("SHELL", "/bin/bash") + dockerfile.WriteAdd("id_rsa", "/home/ubuntu/.ssh/id_rsa") + dockerfile.WriteRun("sudo chown -R ubuntu:ubuntu /home/ubuntu/.ssh") + dockerfile.WriteRun("sudo chown -R ubuntu:ubuntu /var/cache/drone") + dockerfile.WriteRun("sudo chown -R ubuntu:ubuntu /usr/local/bin/drone") + dockerfile.WriteRun("sudo chmod 600 /home/ubuntu/.ssh/id_rsa") + default: + // all other images are assumed to use + // the root user. + dockerfile.WriteUser("root") + dockerfile.WriteEnv("HOME", "/root") + dockerfile.WriteEnv("LANG", "en_US.UTF-8") + dockerfile.WriteEnv("LANGUAGE", "en_US:en") + dockerfile.WriteEnv("LOGNAME", "root") + dockerfile.WriteEnv("TERM", "xterm") + dockerfile.WriteEnv("SHELL", "/bin/bash") + dockerfile.WriteEnv("GOPATH", "/var/cache/drone") + dockerfile.WriteAdd("id_rsa", "/root/.ssh/id_rsa") + dockerfile.WriteRun("chmod 600 /root/.ssh/id_rsa") + } + + dockerfile.WriteAdd("proxy.sh", "/etc/drone.d/") + dockerfile.WriteEntrypoint("/bin/bash -e /usr/local/bin/drone") + + // write the Dockerfile to the temporary directory + return ioutil.WriteFile(filepath.Join(dir, "Dockerfile"), dockerfile.Bytes(), 0700) +} + +// writeBuildScript is a helper function that +// will generate the build script file in the builder's +// temp directory to be added to the Image. +func (b *Builder) writeBuildScript(dir string) error { + f := buildfile.New() + + // if the repository is remote then we should + // add the commands to the build script to + // clone the repository + if b.Repo.IsRemote() { + for _, cmd := range b.Repo.Commands() { + f.WriteCmd(cmd) + } + } + + // if the commit is for merging a pull request + // we should only execute the build commands, + // and omit the deploy and publish commands. + if len(b.Repo.PR) == 0 { + b.Build.Write(f) + } else { + // only write the build commands + b.Build.WriteBuild(f) + } + + scriptfilePath := filepath.Join(dir, "drone") + return ioutil.WriteFile(scriptfilePath, f.Bytes(), 0700) +} + +// writeProxyScript is a helper function that +// will generate the proxy.sh file in the builder's +// temp directory to be added to the Image. +func (b *Builder) writeProxyScript(dir string) error { + var proxyfile = proxy.Proxy{} + + // loop through services so that we can + // map ip address to localhost + for _, container := range b.services { + // create an entry for each port + for port, _ := range container.NetworkSettings.Ports { + proxyfile.Set(port.Port(), container.NetworkSettings.IPAddress) + } + } + + // write the proxyfile to the temp directory + proxyfilePath := filepath.Join(dir, "proxy.sh") + return ioutil.WriteFile(proxyfilePath, proxyfile.Bytes(), 0755) +} + +// writeIdentifyFile is a helper function that +// will generate the id_rsa file in the builder's +// temp directory to be added to the Image. +func (b *Builder) writeIdentifyFile(dir string) error { + keyfilePath := filepath.Join(dir, "id_rsa") + return ioutil.WriteFile(keyfilePath, b.Key, 0700) +} diff --git a/pkg/build/buildfile/buildfile.go b/pkg/build/buildfile/buildfile.go new file mode 100644 index 000000000..9e6d8fad8 --- /dev/null +++ b/pkg/build/buildfile/buildfile.go @@ -0,0 +1,72 @@ +package buildfile + +import ( + "bytes" + "fmt" +) + +type Buildfile struct { + bytes.Buffer +} + +func New() *Buildfile { + b := Buildfile{} + b.WriteString(base) + return &b +} + +// WriteCmd writes a command to the build file. The +// command will be echoed back as a base16 encoded +// command so that it can be parsed and appended to +// the build output +func (b *Buildfile) WriteCmd(command string) { + // echo the command as an encoded value + b.WriteString(fmt.Sprintf("echo '#DRONE:%x'\n", command)) + // and then run the command + b.WriteString(fmt.Sprintf("%s\n", command)) +} + +// WriteCmdSilent writes a command to the build file +// but does not echo the command. +func (b *Buildfile) WriteCmdSilent(command string) { + b.WriteString(fmt.Sprintf("%s\n", command)) +} + +// WriteComment adds a comment to the build file. This +// is really only used internally for debugging purposes. +func (b *Buildfile) WriteComment(comment string) { + b.WriteString(fmt.Sprintf("#%s\n", comment)) +} + +// WriteEnv exports the environment variable as +// part of the script. The environment variables +// are not echoed back to the console, and are +// kept private by default. +func (b *Buildfile) WriteEnv(key, value string) { + b.WriteString(fmt.Sprintf("export %s=%s\n", key, value)) +} + +// every build script starts with the following +// code at the start. +var base = ` +#!/bin/bash + +# drone configuration files are stored in /etc/drone.d +# execute these files prior to our build to set global +# environment variables and initialize programs (like rbenv) +if [ -d /etc/drone.d ]; then + for i in /etc/drone.d/*.sh; do + if [ -r $i ]; then + . $i + fi + done + unset i +fi + +# be sure to exit on error and print out +# our bash commands, so we can which commands +# are executing and troubleshoot failures. +set -e + +# user-defined commands below ############################## +` diff --git a/pkg/build/docker/client.go b/pkg/build/docker/client.go new file mode 100644 index 000000000..1bfcbd660 --- /dev/null +++ b/pkg/build/docker/client.go @@ -0,0 +1,258 @@ +package docker + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "net/http/httputil" + "os" + "strings" + + "github.com/dotcloud/docker/pkg/term" + "github.com/dotcloud/docker/utils" +) + +const ( + APIVERSION = 1.9 + DEFAULTHTTPPORT = 4243 + DEFAULTUNIXSOCKET = "/var/run/docker.sock" + DEFAULTPROTOCOL = "unix" + DEFAULTTAG = "latest" + VERSION = "0.8.0" +) + +// Enables verbose logging to the Terminal window +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.Images = &ImageService{c} + c.Containers = &ContainerService{c} + return c +} + +type Client struct { + proto string + addr string + + Images *ImageService + Containers *ContainerService +} + +var ( + // Returned if the specified resource does not exist. + ErrNotFound = errors.New("Not Found") + + // Returned if the caller attempts to make a call or modify a resource + // for which the caller is not authorized. + // + // The request was a valid request, the caller's authentication credentials + // succeeded but those credentials do not grant the caller permission to + // access the resource. + ErrForbidden = errors.New("Forbidden") + + // Returned if the call requires authentication and either the credentials + // provided failed or no credentials were provided. + ErrNotAuthorized = errors.New("Unauthorized") + + // Returned if the caller submits a badly formed request. For example, + // the caller can receive this return if you forget a required parameter. + ErrBadRequest = errors.New("Bad Request") +) + +// 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 + var payload io.Reader + if in != nil { + buf, err := json.Marshal(in) + if err != nil { + return err + } + payload = bytes.NewBuffer(buf) + } + + // create the request + req, err := http.NewRequest(method, fmt.Sprintf("/v%g%s", APIVERSION, path), payload) + if err != nil { + return err + } + + // set the appropariate headers + req.Header = http.Header{} + req.Header.Set("User-Agent", "Docker-Client/"+VERSION) + req.Header.Set("Content-Type", "application/json") + + // dial the host server + req.Host = c.addr + dial, err := net.Dial(c.proto, c.addr) + if err != nil { + return err + } + + // make the request + conn := httputil.NewClientConn(dial, nil) + resp, err := conn.Do(req) + defer conn.Close() + if err != nil { + return err + } + + // Read the bytes from the body (make sure we defer close the body) + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + // Check for an http error status (ie not 200 StatusOK) + switch resp.StatusCode { + case 404: + return ErrNotFound + case 403: + return ErrForbidden + case 401: + return ErrNotAuthorized + case 400: + return ErrBadRequest + } + + // Unmarshall the JSON response + if out != nil { + return json.Unmarshal(body, out) + } + + return nil +} + +func (c *Client) hijack(method, path string, setRawTerminal bool, out io.Writer) error { + req, err := http.NewRequest(method, fmt.Sprintf("/v%g%s", APIVERSION, path), nil) + if err != nil { + return err + } + + req.Header.Set("User-Agent", "Docker-Client/"+VERSION) + req.Header.Set("Content-Type", "plain/text") + req.Host = c.addr + + dial, err := net.Dial(c.proto, c.addr) + if err != nil { + if strings.Contains(err.Error(), "connection refused") { + return fmt.Errorf("Can't connect to docker daemon. Is 'docker -d' running on this host?") + } + return err + } + clientconn := httputil.NewClientConn(dial, nil) + defer clientconn.Close() + + // Server hijacks the connection, error 'connection closed' expected + clientconn.Do(req) + + // Hijack the connection to read / write + rwc, br := clientconn.Hijack() + defer rwc.Close() + + // launch a goroutine to copy the stream + // of build output to the writer. + errStdout := make(chan error, 1) + go func() { + var err error + if setRawTerminal { + _, err = io.Copy(out, br) + } else { + _, err = utils.StdCopy(out, out, br) + } + + errStdout <- err + }() + + // wait for a response + if err := <-errStdout; err != nil { + return err + } + return nil +} + +func (c *Client) stream(method, path string, in io.Reader, out io.Writer, headers http.Header) error { + if (method == "POST" || method == "PUT") && in == nil { + in = bytes.NewReader(nil) + } + + // setup the request + req, err := http.NewRequest(method, fmt.Sprintf("/v%g%s", APIVERSION, path), in) + if err != nil { + return err + } + + // set default headers + req.Header = headers + req.Header.Set("User-Agent", "Docker-Client/0.6.4") + req.Header.Set("Content-Type", "plain/text") + + // dial the host server + req.Host = c.addr + dial, err := net.Dial(c.proto, c.addr) + if err != nil { + return err + } + + // make the request + conn := httputil.NewClientConn(dial, nil) + resp, err := conn.Do(req) + defer conn.Close() + if err != nil { + return err + } + + // make sure we defer close the body + defer resp.Body.Close() + + // Check for an http error status (ie not 200 StatusOK) + switch resp.StatusCode { + case 404: + return ErrNotFound + case 403: + return ErrForbidden + case 401: + return ErrNotAuthorized + case 400: + return ErrBadRequest + } + + // If no output we exit now with no errors + if out == nil { + return nil + } + + // copy the output stream to the writer + if resp.Header.Get("Content-Type") == "application/json" { + var terminalFd = os.Stdin.Fd() + var isTerminal = term.IsTerminal(terminalFd) + + // it may not make sense to put this code here, but it works for + // us at the moment, and I don't feel like refactoring + return utils.DisplayJSONMessagesStream(resp.Body, out, terminalFd, isTerminal) + } + // otherwise plain text + if _, err := io.Copy(out, resp.Body); err != nil { + return err + } + + return nil +} diff --git a/pkg/build/docker/container.go b/pkg/build/docker/container.go new file mode 100644 index 000000000..31f54b0e3 --- /dev/null +++ b/pkg/build/docker/container.go @@ -0,0 +1,147 @@ +package docker + +import ( + "fmt" + "io" +) + +type ContainerService struct { + *Client +} + +// List only running containers. +func (c *ContainerService) List() ([]*Containers, error) { + containers := []*Containers{} + err := c.do("GET", "/containers/json?all=0", nil, &containers) + return containers, err +} + +// List all containers +func (c *ContainerService) ListAll() ([]*Containers, error) { + containers := []*Containers{} + err := c.do("GET", "/containers/json?all=1", nil, &containers) + return containers, err +} + +// Create a Container +func (c *ContainerService) Create(conf *Config) (*Run, error) { + run, err := c.create(conf) + switch { + // if no error, exit immediately + case err == nil: + return run, nil + // if error we exit, unless it is + // a NOT FOUND error, which means we just + // need to download the Image from the center + // image index + case err != nil && err != ErrNotFound: + return nil, err + } + + // attempt to pull the image + if err := c.Images.Pull(conf.Image); err != nil { + return nil, err + } + + // now that we have the image, re-try creation + return c.create(conf) +} + +func (c *ContainerService) create(conf *Config) (*Run, error) { + run := Run{} + err := c.do("POST", "/containers/create", conf, &run) + return &run, err +} + +// Start the container id +func (c *ContainerService) Start(id string, conf *HostConfig) error { + return c.do("POST", fmt.Sprintf("/containers/%s/start", id), &conf, nil) +} + +// Stop the container id +func (c *ContainerService) Stop(id string, timeout int) error { + return c.do("POST", fmt.Sprintf("/containers/%s/stop?t=%v", id, timeout), nil, nil) +} + +// Remove the container id from the filesystem. +func (c *ContainerService) Remove(id string) error { + return c.do("DELETE", fmt.Sprintf("/containers/%s", id), nil, nil) +} + +// Block until container id stops, then returns the exit code +func (c *ContainerService) Wait(id string) (*Wait, error) { + wait := Wait{} + err := c.do("POST", fmt.Sprintf("/containers/%s/wait", id), nil, &wait) + return &wait, err +} + +// Attach to the container to stream the stdout and stderr +func (c *ContainerService) Attach(id string, out io.Writer) error { + path := fmt.Sprintf("/containers/%s/attach?&stream=1&stdout=1&stderr=1", id) + return c.hijack("POST", path, false, out) +} + +// Stop the container id +func (c *ContainerService) Inspect(id string) (*Container, error) { + container := Container{} + err := c.do("GET", fmt.Sprintf("/containers/%s/json", id), nil, &container) + return &container, err +} + +// Run the container +func (c *ContainerService) Run(conf *Config, host *HostConfig, out io.Writer) (*Wait, error) { + // create the container from the image + run, err := c.Create(conf) + if err != nil { + return nil, err + } + + // attach to the container + go func() { + c.Attach(run.ID, out) + }() + + // start the container + if err := c.Start(run.ID, host); err != nil { + return nil, err + } + + // wait for the container to stop + wait, err := c.Wait(run.ID) + if err != nil { + return nil, err + } + + return wait, nil +} + +// Run the container as a Daemon +func (c *ContainerService) RunDaemon(conf *Config, host *HostConfig) (*Run, error) { + run, err := c.Create(conf) + if err != nil { + return nil, err + } + + // start the container + err = c.Start(run.ID, host) + return run, err +} + +func (c *ContainerService) RunDaemonPorts(image string, ports ...string) (*Run, error) { + // setup configuration + config := Config{Image: image} + config.ExposedPorts = make(map[Port]struct{}) + + // host configuration + host := HostConfig{} + host.PortBindings = make(map[Port][]PortBinding) + + // loop through and add ports + for _, port := range ports { + config.ExposedPorts[Port(port+"/tcp")] = struct{}{} + host.PortBindings[Port(port+"/tcp")] = []PortBinding{{HostIp: "127.0.0.1", HostPort: ""}} + } + //127.0.0.1::%s + //map[3306/tcp:{}] map[3306/tcp:[{127.0.0.1 }]] + return c.RunDaemon(&config, &host) +} diff --git a/pkg/build/docker/image.go b/pkg/build/docker/image.go new file mode 100644 index 000000000..259128f86 --- /dev/null +++ b/pkg/build/docker/image.go @@ -0,0 +1,124 @@ +package docker + +import ( + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "time" + + "github.com/dotcloud/docker/archive" + "github.com/dotcloud/docker/utils" +) + +type Images struct { + ID string `json:"Id"` + RepoTags []string `json:",omitempty"` + Created int64 + Size int64 + VirtualSize int64 + ParentId string `json:",omitempty"` + + // DEPRECATED + Repository string `json:",omitempty"` + Tag string `json:",omitempty"` +} + +type Image struct { + ID string `json:"id"` + Parent string `json:"parent,omitempty"` + Comment string `json:"comment,omitempty"` + Created time.Time `json:"created"` + Container string `json:"container,omitempty"` + ContainerConfig Config `json:"container_config,omitempty"` + DockerVersion string `json:"docker_version,omitempty"` + Author string `json:"author,omitempty"` + Config *Config `json:"config,omitempty"` + Architecture string `json:"architecture,omitempty"` + OS string `json:"os,omitempty"` + Size int64 +} + +type Delete struct { + Deleted string `json:",omitempty"` + Untagged string `json:",omitempty"` +} + +type ImageService struct { + *Client +} + +// List Images +func (c *ImageService) List() ([]*Images, error) { + images := []*Images{} + err := c.do("GET", "/images/json?all=0", nil, &images) + return images, err +} + +// 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) +} + +func (c *ImageService) Pull(image string) error { + name, tag := utils.ParseRepositoryTag(image) + if len(tag) == 0 { + tag = DEFAULTTAG + } + return c.PullTag(name, tag) +} + +func (c *ImageService) PullTag(name, tag string) error { + var out io.Writer + if Logging { + out = os.Stdout + } + + path := fmt.Sprintf("/images/create?fromImage=%s&tag=%s", name, tag) + return c.stream("POST", path, nil, out, http.Header{}) +} + +// Remove the image name from the filesystem +func (c *ImageService) Remove(image string) ([]*Delete, error) { + resp := []*Delete{} + err := c.do("DELETE", fmt.Sprintf("/images/%s", image), nil, &resp) + return resp, err +} + +// Inspect the image +func (c *ImageService) Inspect(name string) (*Image, error) { + image := Image{} + err := c.do("GET", fmt.Sprintf("/images/%s/json", name), nil, &image) + return &image, err +} + +// Build the Image +func (c *ImageService) Build(tag, dir string) error { + + // tar the file + context, err := archive.Tar(dir, archive.Uncompressed) + if err != nil { + return err + } + + var body io.Reader + body = ioutil.NopCloser(context) + + // Upload the build context + v := url.Values{} + v.Set("t", tag) + v.Set("q", "1") + //v.Set("rm", "1") + + // url path + path := fmt.Sprintf("/build?%s", v.Encode()) + + // set content type to tar file + headers := http.Header{} + headers.Set("Content-Type", "application/tar") + + // make the request + return c.stream("POST", path, body, nil, headers) +} diff --git a/pkg/build/docker/structs.go b/pkg/build/docker/structs.go new file mode 100644 index 000000000..be0aeaa03 --- /dev/null +++ b/pkg/build/docker/structs.go @@ -0,0 +1,166 @@ +package docker + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// These are structures copied from the Docker project. +// We avoid importing the libraries due to a CGO +// depenency on libdevmapper that we'd like to avoid. + +type KeyValuePair struct { + Key string + Value string +} + +type HostConfig struct { + Binds []string + ContainerIDFile string + LxcConf []KeyValuePair + Privileged bool + PortBindings map[Port][]PortBinding + Links []string + PublishAllPorts bool +} + +type Top struct { + Titles []string + Processes [][]string +} + +type Containers struct { + ID string `json:"Id"` + Image string + Command string + Created int64 + Status string + Ports []Port + SizeRw int64 + SizeRootFs int64 + Names []string +} + +type Run struct { + ID string `json:"Id"` + Warnings []string `json:",omitempty"` +} + +type Wait struct { + StatusCode int +} + +type State struct { + Running bool + Pid int + ExitCode int + StartedAt time.Time + FinishedAt time.Time + Ghost bool +} + +type PortBinding struct { + HostIp string + HostPort string +} + +// 80/tcp +type Port string + +func (p Port) Proto() string { + parts := strings.Split(string(p), "/") + if len(parts) == 1 { + return "tcp" + } + return parts[1] +} + +func (p Port) Port() string { + return strings.Split(string(p), "/")[0] +} + +func (p Port) Int() int { + i, err := parsePort(p.Port()) + if err != nil { + panic(err) + } + return i +} + +func parsePort(rawPort string) (int, error) { + port, err := strconv.ParseUint(rawPort, 10, 16) + if err != nil { + return 0, err + } + return int(port), nil +} + +func NewPort(proto, port string) Port { + return Port(fmt.Sprintf("%s/%s", port, proto)) +} + +type PortMapping map[string]string // Deprecated + +type NetworkSettings struct { + IPAddress string + IPPrefixLen int + Gateway string + Bridge string + PortMapping map[string]PortMapping // Deprecated + Ports map[Port][]PortBinding +} + +type Config struct { + Hostname string + Domainname string + User string + Memory int64 // Memory limit (in bytes) + MemorySwap int64 // Total memory usage (memory + swap); set `-1' to disable swap + CpuShares int64 // CPU shares (relative weight vs. other containers) + AttachStdin bool + AttachStdout bool + AttachStderr bool + PortSpecs []string // Deprecated - Can be in the format of 8080/tcp + ExposedPorts map[Port]struct{} + Tty bool // Attach standard streams to a tty, including stdin if it is not closed. + OpenStdin bool // Open stdin + StdinOnce bool // If true, close stdin after the 1 attached client disconnects. + Env []string + Cmd []string + Dns []string + Image string // Name of the image as it was passed by the operator (eg. could be symbolic) + Volumes map[string]struct{} + VolumesFrom string + WorkingDir string + Entrypoint []string + NetworkDisabled bool +} + +type Container struct { + ID string + + Created time.Time + + Path string + Args []string + + Config *Config + State State + Image string + + NetworkSettings *NetworkSettings + + SysInitPath string + ResolvConfPath string + HostnamePath string + HostsPath string + Name string + Driver string + + Volumes map[string]string + // Store rw/ro in a separate structure to preserve reverse-compatibility on-disk. + // Easier than migrating older container configs :) + VolumesRW map[string]bool +} diff --git a/pkg/build/dockerfile/dockerfile.go b/pkg/build/dockerfile/dockerfile.go new file mode 100644 index 000000000..cab6eade7 --- /dev/null +++ b/pkg/build/dockerfile/dockerfile.go @@ -0,0 +1,44 @@ +package dockerfile + +import ( + "bytes" + "fmt" +) + +type Dockerfile struct { + bytes.Buffer +} + +func New(from string) *Dockerfile { + d := Dockerfile{} + d.WriteFrom(from) + return &d +} + +func (d *Dockerfile) WriteAdd(from, to string) { + d.WriteString(fmt.Sprintf("ADD %s %s\n", from, to)) +} + +func (d *Dockerfile) WriteFrom(from string) { + d.WriteString(fmt.Sprintf("FROM %s\n", from)) +} + +func (d *Dockerfile) WriteRun(cmd string) { + d.WriteString(fmt.Sprintf("RUN %s\n", cmd)) +} + +func (d *Dockerfile) WriteUser(user string) { + d.WriteString(fmt.Sprintf("USER %s\n", user)) +} + +func (d *Dockerfile) WriteEnv(key, val string) { + d.WriteString(fmt.Sprintf("ENV %s %s\n", key, val)) +} + +func (d *Dockerfile) WriteWorkdir(workdir string) { + d.WriteString(fmt.Sprintf("WORKDIR %s\n", workdir)) +} + +func (d *Dockerfile) WriteEntrypoint(entrypoint string) { + d.WriteString(fmt.Sprintf("ENTRYPOINT %s\n", entrypoint)) +} diff --git a/pkg/build/images.go b/pkg/build/images.go new file mode 100644 index 000000000..d887ee367 --- /dev/null +++ b/pkg/build/images.go @@ -0,0 +1,238 @@ +package build + +type image struct { + // default ports the service will run on. + // for example, 3306 for mysql. Note that a service + // may expose multiple prots, for example, Riak + // exposes 8087 and 8089. + Ports []string + + // tag of the docker image to pull in order + // to run this service. + Tag string + + // display name of the image type + Name string +} + +// List of 3rd party services (database, queue, etc) that +// are known to work with this Build utility. +var services = map[string]*image{ + + // neo4j + "neo4j": { + Ports: []string{"7474"}, + Tag: "bradrydzewski/neo4j:1.9", + Name: "neo4j", + }, + "neo4j:1.9": { + Ports: []string{"7474"}, + Tag: "bradrydzewski/neo4j:1.9", + Name: "neo4j", + }, + + // elasticsearch servers + "elasticsearch": { + Ports: []string{"9200"}, + Tag: "bradrydzewski/elasticsearch:0.90", + Name: "elasticsearch", + }, + "elasticsearch:0.20": { + Ports: []string{"9200"}, + Tag: "bradrydzewski/elasticsearch:0.20", + Name: "elasticsearch", + }, + "elasticsearch:0.90": { + Ports: []string{"9200"}, + Tag: "bradrydzewski/elasticsearch:0.90", + Name: "elasticsearch", + }, + + // redis servers + "redis": { + Ports: []string{"6379"}, + Tag: "bradrydzewski/redis:2.8", + Name: "redis", + }, + "redis:2.8": { + Ports: []string{"6379"}, + Tag: "bradrydzewski/redis:2.8", + Name: "redis", + }, + "redis:2.6": { + Ports: []string{"6379"}, + Tag: "bradrydzewski/redis:2.6", + Name: "redis", + }, + + // mysql servers + "mysql": { + Tag: "bradrydzewski/mysql:5.5", + Ports: []string{"3306"}, + Name: "mysql", + }, + "mysql:5.5": { + Tag: "bradrydzewski/mysql:5.5", + Ports: []string{"3306"}, + Name: "mysql", + }, + + // memcached + "memcached": { + Ports: []string{"11211"}, + Tag: "bradrydzewski/memcached", + Name: "memcached", + }, + + // mongodb + "mongodb": { + Ports: []string{"27017"}, + Tag: "bradrydzewski/mongodb:2.4", + Name: "mongodb", + }, + "mongodb:2.4": { + Ports: []string{"27017"}, + Tag: "bradrydzewski/mongodb:2.4", + Name: "mongodb", + }, + "mongodb:2.2": { + Ports: []string{"27017"}, + Tag: "bradrydzewski/mongodb:2.2", + Name: "mongodb", + }, + + // postgres + "postgres": { + Ports: []string{"5432"}, + Tag: "bradrydzewski/postgres:9.1", + Name: "postgres", + }, + "postgres:9.1": { + Ports: []string{"5432"}, + Tag: "bradrydzewski/postgres:9.1", + Name: "postgres", + }, + + // couchdb + "couchdb": { + Ports: []string{"5984"}, + Tag: "bradrydzewski/couchdb:1.0", + Name: "couchdb", + }, + "couchdb:1.0": { + Ports: []string{"5984"}, + Tag: "bradrydzewski/couchdb:1.0", + Name: "couchdb", + }, + "couchdb:1.4": { + Ports: []string{"5984"}, + Tag: "bradrydzewski/couchdb:1.4", + Name: "couchdb", + }, + "couchdb:1.5": { + Ports: []string{"5984"}, + Tag: "bradrydzewski/couchdb:1.5", + Name: "couchdb", + }, + + // rabbitmq + "rabbitmq": { + Ports: []string{"5672", "15672"}, + Tag: "bradrydzewski/rabbitmq:3.2", + Name: "rabbitmq", + }, + "rabbitmq:3.2": { + Ports: []string{"5672", "15672"}, + Tag: "bradrydzewski/rabbitmq:3.2", + Name: "rabbitmq", + }, + + // experimental images from 3rd parties + + "zookeeper": { + Ports: []string{"2181"}, + Tag: "jplock/zookeeper:3.4.5", + Name: "zookeeper", + }, + + // cassandra + "cassandra": { + Ports: []string{"9042", "7000", "7001", "7199", "9160", "49183"}, + Tag: "relateiq/cassandra", + Name: "cassandra", + }, + + // riak - TESTED + "riak": { + Ports: []string{"8087", "8098"}, + Tag: "guillermo/riak", + Name: "riak", + }, +} + +// List of official Drone build images. +var builders = map[string]*image{ + + // Clojure build images + "lein": {Tag: "bradrydzewski/lein"}, + + // Dart build images + "dart": {Tag: "bradrydzewski/dart:stable"}, + "dart_stable": {Tag: "bradrydzewski/dart:stable"}, + "dart_dev": {Tag: "bradrydzewski/dart:dev"}, + + // Erlang build images + "erlang": {Tag: "bradrydzewski/erlang:R16B02"}, + "erlangR16B": {Tag: "bradrydzewski/erlang:R16B"}, + "erlangR16B02": {Tag: "bradrydzewski/erlang:R16B02"}, + "erlangR16B01": {Tag: "bradrydzewski/erlang:R16B01"}, + + // GCC build images + "gcc": {Tag: "bradrydzewski/gcc:4.6"}, + "gcc4.6": {Tag: "bradrydzewski/gcc:4.6"}, + "gcc4.8": {Tag: "bradrydzewski/gcc:4.8"}, + + // Golang build images + "go": {Tag: "bradrydzewski/go:1.2"}, + "go1": {Tag: "bradrydzewski/go:1.0"}, + "go1.1": {Tag: "bradrydzewski/go:1.1"}, + "go1.2": {Tag: "bradrydzewski/go:1.2"}, + + // Haskell build images + "haskell": {Tag: "bradrydzewski/haskell:7.4"}, + "haskell7.4": {Tag: "bradrydzewski/haskell:7.4"}, + + // Java build images + "java": {Tag: "bradrydzewski/java:openjdk7"}, + "openjdk6": {Tag: "bradrydzewski/java:openjdk6"}, + "openjdk7": {Tag: "bradrydzewski/java:openjdk7"}, + "oraclejdk7": {Tag: "bradrydzewski/java:oraclejdk7"}, + "oraclejdk8": {Tag: "bradrydzewski/java:oraclejdk8"}, + + // Node build images + "node": {Tag: "bradrydzewski/node:0.10"}, + "node0.10": {Tag: "bradrydzewski/node:0.10"}, + "node0.8": {Tag: "bradrydzewski/node:0.8"}, + + // PHP build images + "php": {Tag: "bradrydzewski/php:5.5"}, + "php5.5": {Tag: "bradrydzewski/php:5.5"}, + "php5.4": {Tag: "bradrydzewski/php:5.4"}, + + // Python build images + "python": {Tag: "bradrydzewski/python:2.7"}, + "python2.7": {Tag: "bradrydzewski/python:2.7"}, + "python3.2": {Tag: "bradrydzewski/python:3.2"}, + "python3.3": {Tag: "bradrydzewski/python:3.3"}, + "pypy": {Tag: "bradrydzewski/python:pypy"}, + + // Ruby build images + "ruby": {Tag: "bradrydzewski/ruby:2.0.0"}, + "ruby2.0.0": {Tag: "bradrydzewski/ruby:2.0.0"}, + "ruby1.9.3": {Tag: "bradrydzewski/ruby:1.9.3"}, + + // Scala build images + "scala": {Tag: "bradrydzewski/scala:2.10.3"}, + "scala2.10.3": {Tag: "bradrydzewski/scala:2.10.3"}, + "scala2.9.3": {Tag: "bradrydzewski/scala:2.9.3"}, +} diff --git a/pkg/build/log/log.go b/pkg/build/log/log.go new file mode 100644 index 000000000..de3556b56 --- /dev/null +++ b/pkg/build/log/log.go @@ -0,0 +1,105 @@ +package log + +import ( + "fmt" + "io" + "os" + "sync" +) + +const ( + LOG_EMERG = iota + LOG_ALERT + LOG_CRIT + LOG_ERR + LOG_WARNING + LOG_NOTICE + LOG_INFO + LOG_DEBUG +) + +var mu sync.Mutex + +// the default Log priority +var priority int = LOG_DEBUG + +// the default Log output destination +var output io.Writer = os.Stdout + +// the log prefix +var prefix string + +// the log suffix +var suffix string = "/n" + +// SetPriority sets the default log level. +func SetPriority(level int) { + mu.Lock() + defer mu.Unlock() + priority = level +} + +// SetOutput sets the output destination. +func SetOutput(w io.Writer) { + mu.Lock() + defer mu.Unlock() + output = w +} + +// SetPrefix sets the prefix for the log message. +func SetPrefix(pre string) { + mu.Lock() + defer mu.Unlock() + prefix = pre +} + +// SetSuffix sets the suffix for the log message. +func SetSuffix(suf string) { + mu.Lock() + defer mu.Unlock() + suffix = suf +} + +func Write(out string, level int) { + mu.Lock() + defer mu.Unlock() + + // append the prefix and suffix + out = prefix + out + suffix + + if priority >= level { + output.Write([]byte(out)) + } +} + +func Debug(out string) { + Write(out, LOG_DEBUG) +} + +func Debugf(format string, a ...interface{}) { + Debug(fmt.Sprintf(format, a...)) +} + +func Info(out string) { + Write(out, LOG_INFO) +} + +func Infof(format string, a ...interface{}) { + Info(fmt.Sprintf(format, a...)) +} + +func Err(out string) { + Write(out, LOG_ERR) +} + +func Errf(format string, a ...interface{}) { + Err(fmt.Sprintf(format, a...)) +} + +func Notice(out string) { + Write(out, LOG_NOTICE) +} + +func Noticef(format string, a ...interface{}) { + Notice(fmt.Sprintf(format, a...)) +} diff --git a/pkg/build/proxy/proxy.go b/pkg/build/proxy/proxy.go new file mode 100644 index 000000000..55ef9a11c --- /dev/null +++ b/pkg/build/proxy/proxy.go @@ -0,0 +1,41 @@ +package proxy + +import ( + "bytes" + "fmt" +) + +// bash header +const header = "#!/bin/bash\n" + +// this command string will check if the socat utility +// exists, and if it does, will proxy connections to +// the external IP address. +const command = "[ -x /usr/bin/socat ] && socat TCP-LISTEN:%s,fork TCP:%s:%s &\n" + +// Proxy stores proxy configuration details mapping +// a local port to an external IP address with the +// same port number. +type Proxy map[string]string + +func (p Proxy) Set(port, ip string) { + p[port] = ip +} + +// String converts the proxy configuration details +// to a bash script. +func (p Proxy) String() string { + var buf bytes.Buffer + buf.WriteString(header) + for port, ip := range p { + buf.WriteString(fmt.Sprintf(command, port, ip, port)) + } + + return buf.String() +} + +// Bytes converts the proxy configuration details +// to a bash script in byte array format. +func (p Proxy) Bytes() []byte { + return []byte(p.String()) +} diff --git a/pkg/build/proxy/proxy_test.go b/pkg/build/proxy/proxy_test.go new file mode 100644 index 000000000..19ef98539 --- /dev/null +++ b/pkg/build/proxy/proxy_test.go @@ -0,0 +1,32 @@ +package proxy + +import ( + "testing" +) + +func TestProxy(t *testing.T) { + // test creating a proxy with a few different + // addresses, and our ability to create the + // proxy shell script. + p := Proxy{} + p.Set("8080", "172.1.4.5") + p.Set("8000", "172.1.3.1") + b := p.Bytes() + + expected := `#!/bin/bash +[ -x /usr/bin/socat ] && socat TCP-LISTEN:8080,fork TCP:172.1.4.5:8080 & +[ -x /usr/bin/socat ] && socat TCP-LISTEN:8000,fork TCP:172.1.3.1:8000 & +` + if string(b) != expected { + t.Errorf("Invalid proxy \n%s", expected) + } + + // test creating a proxy script when there + // are no proxy addresses added to the map + p = Proxy{} + b = p.Bytes() + expected = "#!/bin/bash\n" + if string(b) != expected { + t.Errorf("Invalid proxy \n%s", expected) + } +} diff --git a/pkg/build/repo/repo.go b/pkg/build/repo/repo.go new file mode 100644 index 000000000..581be9688 --- /dev/null +++ b/pkg/build/repo/repo.go @@ -0,0 +1,118 @@ +package repo + +import ( + "fmt" + "strings" +) + +type Repo struct { + // The path of the Repoisotry. This could be + // the remote path of a Git repository or the path of + // of the repository on the local file system. + // + // A remote path must start with http://, https://, + // git://, ssh:// or git@. Otherwise we'll assume + // the repository is located on the local filesystem. + Path string + + // (optional) Specific Branch that we should checkout + // when the Repository is cloned. If no value is + // provided we'll assume the default, master branch. + Branch string + + // (optional) Specific Commit Hash that we should + // checkout when the Repository is cloned. If no + // value is provided we'll assume HEAD. + Commit string + + // (optional) Pull Request number that we should + // checkout when the Repository is cloned. + PR string + + // (optional) The filesystem path that the repository + // will be cloned into (or copied to) inside the + // host system (Docker Container). + Dir string +} + +// IsRemote returns true if the Repository is located +// on a remote server (ie Github, Bitbucket) +func (r *Repo) IsRemote() bool { + switch { + case strings.HasPrefix(r.Path, "git://"): + return true + case strings.HasPrefix(r.Path, "git@"): + return true + case strings.HasPrefix(r.Path, "http://"): + return true + case strings.HasPrefix(r.Path, "https://"): + return true + case strings.HasPrefix(r.Path, "ssh://"): + return true + } + + return false +} + +// IsLocal returns true if the Repository is located +// on the local filesystem. +func (r *Repo) IsLocal() bool { + return !r.IsRemote() +} + +// IsGit returns true if the Repository is +// a Git repoisitory. +func (r *Repo) IsGit() bool { + switch { + case strings.HasPrefix(r.Path, "git://"): + return true + case strings.HasPrefix(r.Path, "git@"): + return true + case strings.HasPrefix(r.Path, "ssh://git@"): + return true + case strings.HasPrefix(r.Path, "https://github.com/"): + return true + case strings.HasPrefix(r.Path, "http://github.com"): + return true + case strings.HasSuffix(r.Path, ".git"): + return true + } + + // we could also ping the repository to check + + return false +} + +// returns commands that can be used in a Dockerfile +// to clone the repository. +// +// 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 + if len(branch) == 0 { + branch = "master" + } + + cmds := []string{} + cmds = append(cmds, fmt.Sprintf("git clone --branch=%s %s %s", branch, r.Path, r.Dir)) + + switch { + // if a specific commit is provided then we'll + // need to clone it. + case len(r.PR) > 0: + + cmds = append(cmds, fmt.Sprintf("git fetch origin +refs/pull/%s/head:refs/remotes/origin/pr/%s", r.PR, r.PR)) + cmds = append(cmds, fmt.Sprintf("git checkout -qf pr/%s", r.PR)) + //cmds = append(cmds, fmt.Sprintf("git fetch origin +refs/pull/%s/merge:", r.PR)) + //cmds = append(cmds, fmt.Sprintf("git checkout -qf %s", "FETCH_HEAD")) + // if a specific commit is provided then we'll + // need to clone it. + case len(r.Commit) > 0: + cmds = append(cmds, fmt.Sprintf("git checkout -qf %s", r.Commit)) + } + + return cmds +} diff --git a/pkg/build/repo/repo_test.go b/pkg/build/repo/repo_test.go new file mode 100644 index 000000000..b8f4d54ef --- /dev/null +++ b/pkg/build/repo/repo_test.go @@ -0,0 +1,54 @@ +package repo + +import ( + "testing" +) + +func TestIsRemote(t *testing.T) { + repos := []struct { + path string + remote bool + }{ + {"git://github.com/foo/far", true}, + {"git://github.com/foo/far.git", true}, + {"git@github.com:foo/far", true}, + {"git@github.com:foo/far.git", true}, + {"http://github.com/foo/far.git", true}, + {"https://github.com/foo/far.git", true}, + {"ssh://baz.com/foo/far.git", true}, + {"/var/lib/src", false}, + {"/home/ubuntu/src", false}, + {"src", false}, + } + + for _, r := range repos { + repo := Repo{Path: r.path} + if remote := repo.IsRemote(); remote != r.remote { + t.Errorf("IsRemote %s was %v, expected %v", r.path, remote, r.remote) + } + } +} + +func TestIsGit(t *testing.T) { + repos := []struct { + path string + remote bool + }{ + {"git://github.com/foo/far", true}, + {"git://github.com/foo/far.git", true}, + {"git@github.com:foo/far", true}, + {"git@github.com:foo/far.git", true}, + {"http://github.com/foo/far.git", true}, + {"https://github.com/foo/far.git", true}, + {"ssh://baz.com/foo/far.git", true}, + {"svn://gcc.gnu.org/svn/gcc/branches/gccgo", false}, + {"https://code.google.com/p/go", false}, + } + + for _, r := range repos { + repo := Repo{Path: r.path} + if remote := repo.IsGit(); remote != r.remote { + t.Errorf("IsGit %s was %v, expected %v", r.path, remote, r.remote) + } + } +} diff --git a/pkg/build/script/deployment/appfog.go b/pkg/build/script/deployment/appfog.go new file mode 100644 index 000000000..367fd2709 --- /dev/null +++ b/pkg/build/script/deployment/appfog.go @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..881410617 --- /dev/null +++ b/pkg/build/script/deployment/cloudcontrol.go @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..1f4620818 --- /dev/null +++ b/pkg/build/script/deployment/cloudfoundry.go @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..eb26dbc2b --- /dev/null +++ b/pkg/build/script/deployment/deployment.go @@ -0,0 +1,42 @@ +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 new file mode 100644 index 000000000..8aefa93d1 --- /dev/null +++ b/pkg/build/script/deployment/engineyard.go @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..3c65985a3 --- /dev/null +++ b/pkg/build/script/deployment/git.go @@ -0,0 +1 @@ +package deployment diff --git a/pkg/build/script/deployment/heroku.go b/pkg/build/script/deployment/heroku.go new file mode 100644 index 000000000..60dd2e371 --- /dev/null +++ b/pkg/build/script/deployment/heroku.go @@ -0,0 +1,38 @@ +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 new file mode 100644 index 000000000..6a0af8a3a --- /dev/null +++ b/pkg/build/script/deployment/nodejitsu.go @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..dc325c742 --- /dev/null +++ b/pkg/build/script/deployment/openshift.go @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..3c65985a3 --- /dev/null +++ b/pkg/build/script/deployment/ssh.go @@ -0,0 +1 @@ +package deployment diff --git a/pkg/build/script/notification/email.go b/pkg/build/script/notification/email.go new file mode 100644 index 000000000..cdb39932b --- /dev/null +++ b/pkg/build/script/notification/email.go @@ -0,0 +1,85 @@ +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 new file mode 100644 index 000000000..5e8def651 --- /dev/null +++ b/pkg/build/script/notification/hipchat.go @@ -0,0 +1,64 @@ +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 new file mode 100644 index 000000000..4306c87f1 --- /dev/null +++ b/pkg/build/script/notification/irc.go @@ -0,0 +1 @@ +package notification diff --git a/pkg/build/script/notification/notification.go b/pkg/build/script/notification/notification.go new file mode 100644 index 000000000..bea1b6e71 --- /dev/null +++ b/pkg/build/script/notification/notification.go @@ -0,0 +1,53 @@ +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 new file mode 100644 index 000000000..649fbbe2c --- /dev/null +++ b/pkg/build/script/notification/webhook.go @@ -0,0 +1,59 @@ +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 new file mode 100644 index 000000000..4306c87f1 --- /dev/null +++ b/pkg/build/script/notification/zapier.go @@ -0,0 +1 @@ +package notification diff --git a/pkg/build/script/publish/bintray.go b/pkg/build/script/publish/bintray.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/build/script/publish/bintray.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/build/script/publish/dropbox.go b/pkg/build/script/publish/dropbox.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/build/script/publish/dropbox.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/build/script/publish/gems.go b/pkg/build/script/publish/gems.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/build/script/publish/gems.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/build/script/publish/maven.go b/pkg/build/script/publish/maven.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/build/script/publish/maven.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/build/script/publish/npm.go b/pkg/build/script/publish/npm.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/build/script/publish/npm.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/build/script/publish/pub.go b/pkg/build/script/publish/pub.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/build/script/publish/pub.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/build/script/publish/publish.go b/pkg/build/script/publish/publish.go new file mode 100644 index 000000000..d31088f29 --- /dev/null +++ b/pkg/build/script/publish/publish.go @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..d46cd60ca --- /dev/null +++ b/pkg/build/script/publish/pypi.go @@ -0,0 +1,2 @@ +package publish + diff --git a/pkg/build/script/publish/s3.go b/pkg/build/script/publish/s3.go new file mode 100644 index 000000000..cfa75e7d1 --- /dev/null +++ b/pkg/build/script/publish/s3.go @@ -0,0 +1,85 @@ +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 new file mode 100644 index 000000000..03260a5b1 --- /dev/null +++ b/pkg/build/script/report/README.md @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..72af02a0f --- /dev/null +++ b/pkg/build/script/script.go @@ -0,0 +1,123 @@ +package script + +import ( + "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" +) + +func ParseBuild(data []byte) (*Build, error) { + build := Build{} + + // parse the build configuration file + err := goyaml.Unmarshal(data, &build) + return &build, err +} + +func ParseBuildFile(filename string) (*Build, error) { + data, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + return ParseBuild(data) +} + +// Build stores the configuration details for +// building, testing and deploying code. +type Build struct { + // Image specifies the Docker Image that will be + // used to virtualize the Build process. + Image string + + // Name specifies a user-defined label used + // to identify the build. + Name string + + // Script specifies the build and test commands. + Script []string + + // Env specifies the environment of the build. + Env []string + + // Services specifies external services, such as + // database or messaging queues, that should be + // 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"` +} + +// Write adds all the steps to the build script, including +// build commands, deploy and publish commands. +func (b *Build) Write(f *buildfile.Buildfile) { + // append build commands + b.WriteBuild(f) + + // write publish commands + if b.Publish != nil { + b.Publish.Write(f) + } + + // write deployment commands + if b.Deploy != nil { + b.Deploy.Write(f) + } +} + +// WriteBuild adds only the build steps to the build script, +// omitting publish and deploy steps. This is important for +// pull requests, where deployment would be undesirable. +func (b *Build) WriteBuild(f *buildfile.Buildfile) { + // append environment variables + for _, env := range b.Env { + parts := strings.Split(env, "=") + if len(parts) != 2 { + continue + } + f.WriteEnv(parts[0], parts[1]) + } + + // append build commands + for _, cmd := range b.Script { + f.WriteCmd(cmd) + } +} + +type Publish interface { + Write(f *buildfile.Buildfile) +} + +type Deployment interface { + Write(f *buildfile.Buildfile) +} + +type Notification interface { + Set(c Context) +} + +type Context interface { + Host() string + Owner() string + Name() string + + Branch() string + Hash() string + Status() string + Message() string + Author() string + Gravatar() string + + Duration() int64 + HumanDuration() string + + //Settings +} diff --git a/pkg/build/util.go b/pkg/build/util.go new file mode 100644 index 000000000..eb96530b3 --- /dev/null +++ b/pkg/build/util.go @@ -0,0 +1,28 @@ +package build + +import ( + "crypto/rand" + "crypto/sha1" + "fmt" + "io" +) + +// createUID is a helper function that will +// create a random, unique identifier. +func createUID() string { + c := sha1.New() + r := createRandom() + io.WriteString(c, string(r)) + s := fmt.Sprintf("%x", c.Sum(nil)) + return "drone-" + s[0:10] +} + +// createRandom creates a random block of bytes +// that we can use to generate unique identifiers. +func createRandom() []byte { + k := make([]byte, sha1.BlockSize) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + return nil + } + return k +} diff --git a/pkg/build/writer.go b/pkg/build/writer.go new file mode 100644 index 000000000..2f5fe9e1c --- /dev/null +++ b/pkg/build/writer.go @@ -0,0 +1,57 @@ +package build + +import ( + //"bytes" + "fmt" + "io" + + "strings" +) + +var ( + // the prefix used to determine if this is + // data that should be stripped from the output + prefix = []byte("#DRONE:") +) + +// custom writer to intercept the build +// output +type writer struct { + io.Writer +} + +// Write appends the contents of p to the buffer. It will +// scan for DRONE special formatting codes embedded in the +// output, and will alter the output accordingly. +func (w *writer) Write(p []byte) (n int, err error) { + + lines := strings.Split(string(p), "\n") + for i, line := range lines { + + if strings.HasPrefix(line, "#DRONE:") { + var cmd string + + // extract the command (base16 encoded) + // from the output + fmt.Sscanf(line[7:], "%x", &cmd) + + // echo the decoded command + cmd = fmt.Sprintf("$ %s", cmd) + w.Writer.Write([]byte(cmd)) + + } else { + w.Writer.Write([]byte(line)) + } + + if i < len(lines)-1 { + w.Writer.Write([]byte("\n")) + } + } + + return len(p), nil +} + +// WriteString appends the contents of s to the buffer. +func (w *writer) WriteString(s string) (n int, err error) { + return w.Write([]byte(s)) +} diff --git a/pkg/build/writer_test.go b/pkg/build/writer_test.go new file mode 100644 index 000000000..255e15d31 --- /dev/null +++ b/pkg/build/writer_test.go @@ -0,0 +1,27 @@ +package build + +import ( + "bytes" + "testing" +) + +func TestSetupDockerfile(t *testing.T) { + var buf bytes.Buffer + + // wrap the buffer so we can analyze output + w := writer{&buf} + + w.WriteString("#DRONE:676f206275696c64\n") + w.WriteString("#DRONE:676f2074657374202d76\n") + w.WriteString("PASS\n") + w.WriteString("ok github.com/garyburd/redigo/redis 0.113s\n") + + expected := `$ go build +$ go test -v +PASS +ok github.com/garyburd/redigo/redis 0.113s +` + if expected != buf.String() { + t.Errorf("Expected commands decoded and echoed correctly. got \n%s", buf.String()) + } +} diff --git a/pkg/channel/channel.go b/pkg/channel/channel.go new file mode 100644 index 000000000..3c908090e --- /dev/null +++ b/pkg/channel/channel.go @@ -0,0 +1,157 @@ +package channel + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "io" + "time" + + "code.google.com/p/go.net/websocket" + "github.com/dchest/authcookie" +) + +// secret key used to generate tokens +var secret = make([]byte, 32) + +func init() { + // generate the secret key by reading + // from crypto/random + if _, err := io.ReadFull(rand.Reader, secret); err != nil { + panic(err) + } +} + +// Create will generate a token and create a new +// channel over which messages will be sent. +func Create(name string) string { + mu.Lock() + defer mu.Unlock() + + if _, ok := hubs[name]; !ok { + hub := newHub(false, true) + hubs[name] = hub + go hub.run() + } + return authcookie.NewSinceNow(name, 24*time.Hour, secret) +} + +// CreateStream will generate a token and create a new +// channel over which messages streams (ie build output) +// are sent. +func CreateStream(name string) string { + mu.Lock() + defer mu.Unlock() + + if _, ok := hubs[name]; !ok { + hub := newHub(true, false) + hubs[name] = hub + go hub.run() + } + return authcookie.NewSinceNow(name, 24*time.Hour, secret) +} + +// Token will generate a token, but will not create +// a new channel. +func Token(name string) string { + return authcookie.NewSinceNow(name, 24*time.Hour, secret) +} + +// Send sends a message on the named channel. +func Send(name string, message string) error { + return SendBytes(name, []byte(message)) +} + +// SendJSON sends a JSON-encoded value on +// the named channel. +func SendJSON(name string, value interface{}) error { + m, err := json.Marshal(value) + if err != nil { + return err + } + + return SendBytes(name, m) +} + +// SendBytes send a message in byte format on +// the named channel. +func SendBytes(name string, value []byte) error { + // get the hub for the specified channel name + mu.RLock() + hub, ok := hubs[name] + mu.RUnlock() + + if !ok { + return fmt.Errorf("channel does not exist") + } + + go hub.Write(value) + return nil +} + +func Read(ws *websocket.Conn) { + + // get the name from the request + hash := ws.Request().FormValue("token") + + // get the hash of the token + name := authcookie.Login(hash, secret) + + // get the hub for the specified channel name + mu.RLock() + hub, ok := hubs[name] + mu.RUnlock() + + // if hub not found, exit + if !ok { + ws.Close() + return + } + + // internal representation of a connection + // maximum queue of 100000 messages + conn := &connection{ + send: make(chan string, 100000), + ws: ws, + } + + // register the connection with the hub + hub.register <- conn + + defer func() { + go func() { + hub.unregister <- conn + }() + closed := <-hub.closed + + // this will remove the hub when the connection is + // closed if the + if hub.autoClose && closed { + mu.Lock() + delete(hubs, name) + mu.Unlock() + } + }() + + go conn.writer() + conn.reader() +} + +func Close(name string) { + // get the hub for the specified channel name + mu.RLock() + hub, ok := hubs[name] + mu.RUnlock() + + if !ok { + return + } + + // close hub connections + hub.Close() + + // remove the hub + mu.Lock() + delete(hubs, name) + mu.Unlock() +} diff --git a/pkg/channel/conn.go b/pkg/channel/conn.go new file mode 100644 index 000000000..d8c8fac55 --- /dev/null +++ b/pkg/channel/conn.go @@ -0,0 +1,36 @@ +package channel + +import ( + "code.google.com/p/go.net/websocket" +) + +type connection struct { + // The websocket connection. + ws *websocket.Conn + + // Buffered channel of outbound messages. + send chan string +} + +func (c *connection) reader() { + for { + var message string + err := websocket.Message.Receive(c.ws, &message) + if err != nil { + break + } + } + + c.ws.Close() +} + +func (c *connection) writer() { + for message := range c.send { + err := websocket.Message.Send(c.ws, message) + if err != nil { + break + } + } + + c.ws.Close() +} diff --git a/pkg/channel/hub.go b/pkg/channel/hub.go new file mode 100644 index 000000000..8cea6d433 --- /dev/null +++ b/pkg/channel/hub.go @@ -0,0 +1,133 @@ +package channel + +import ( + "sync" +) + +// mutex to lock access to the +// internal map of hubs. +var mu sync.RWMutex + +// a map of hubs. each hub represents a different +// channel that a set of users can listen on. For +// example, we may have a hub to stream build output +// for github.com/foo/bar or a channel to post +// updates for user octocat. +var hubs = map[string]*hub{} + +type hub struct { + // Registered connections + connections map[*connection]bool + + // Inbound messages from the connections. + broadcast chan string + + // Register requests from the connections. + register chan *connection + + // Unregister requests from connections. + unregister chan *connection + + // Buffer of sent data. This is used mostly + // for build output. A client may connect after + // the build has already started, in which case + // we need to stream them the build history. + history []string + + // Send a "shutdown" signal + close chan bool + + // Hub responds on this channel letting you know + // if it's active + closed chan bool + + // Auto shutdown when last connection removed + autoClose bool + + // Send history + sendHistory bool +} + +func newHub(sendHistory, autoClose bool) *hub { + h := hub{ + broadcast: make(chan string), + register: make(chan *connection), + unregister: make(chan *connection), + connections: make(map[*connection]bool), + history: make([]string, 0), // This should be pre-allocated, but it's not + close: make(chan bool), + autoClose: autoClose, + closed: make(chan bool), + sendHistory: sendHistory, + } + + return &h +} + +func sendHistory(c *connection, history []string) { + if len(history) > 0 { + for i := range history { + c.send <- history[i] + } + } +} + +func (h *hub) run() { + // make sure we don't bring down the application + // if somehow we encounter a nil pointer or some + // other unexpected behavior. + defer func() { + recover() + }() + + for { + select { + case c := <-h.register: + h.connections[c] = true + if len(h.history) > 0 { + b := make([]string, len(h.history)) + copy(b, h.history) + go sendHistory(c, b) + } + case c := <-h.unregister: + delete(h.connections, c) + close(c.send) + shutdown := h.autoClose && (len(h.connections) == 0) + if shutdown { + h.closed <- shutdown + return + } + h.closed <- shutdown + case m := <-h.broadcast: + if h.sendHistory { + h.history = append(h.history, m) + } + for c := range h.connections { + select { + case c.send <- m: + // do nothing + default: + delete(h.connections, c) + go c.ws.Close() + } + } + case <-h.close: + for c := range h.connections { + delete(h.connections, c) + close(c.send) + } + h.closed <- true + return + } + + } +} + +func (h *hub) Close() { + h.close <- true +} + +func (h *hub) Write(p []byte) (n int, err error) { + h.broadcast <- string(p) + return len(p), nil +} diff --git a/pkg/database/builds.go b/pkg/database/builds.go new file mode 100644 index 000000000..8aacf32dd --- /dev/null +++ b/pkg/database/builds.go @@ -0,0 +1,71 @@ +package database + +import ( + . "github.com/drone/drone/pkg/model" + "github.com/russross/meddler" +) + +// Name of the Build table in the database +const buildTable = "builds" + +// SQL Queries to retrieve a list of all Commits belonging to a Repo. +const buildStmt = ` +SELECT id, commit_id, slug, status, started, finished, duration, created, updated, stdout +FROM builds +WHERE commit_id = ? +ORDER BY slug ASC +` + +// SQL Queries to retrieve a Build by id. +const buildFindStmt = ` +SELECT id, commit_id, slug, status, started, finished, duration, created, updated, stdout +FROM builds +WHERE id = ? +LIMIT 1 +` + +// SQL Queries to retrieve a Commit by name and repo id. +const buildFindSlugStmt = ` +SELECT id, commit_id, slug, status, started, finished, duration, created, updated, stdout +FROM builds +WHERE slug = ? AND commit_id = ? +LIMIT 1 +` + +// SQL Queries to delete a Commit. +const buildDeleteStmt = ` +DELETE FROM builds WHERE id = ? +` + +// Returns the Build with the given ID. +func GetBuild(id int64) (*Build, error) { + build := Build{} + err := meddler.QueryRow(db, &build, buildFindStmt, id) + return &build, err +} + +// Returns the Build with the given slug. +func GetBuildSlug(slug string, commit int64) (*Build, error) { + build := Build{} + err := meddler.QueryRow(db, &build, buildFindSlugStmt, slug, commit) + return &build, err +} + +// Creates a new Build. +func SaveBuild(build *Build) error { + return meddler.Save(db, buildTable, build) +} + +// Deletes an existing Build. +func DeleteBuild(id int64) error { + _, err := db.Exec(buildDeleteStmt, id) + return err +} + +// Returns a list of all Builds associated +// with the specified Commit ID and branch. +func ListBuilds(id int64) ([]*Build, error) { + var builds []*Build + err := meddler.QueryAll(db, &builds, buildStmt, id) + return builds, err +} diff --git a/pkg/database/commits.go b/pkg/database/commits.go new file mode 100644 index 000000000..74e0d86b4 --- /dev/null +++ b/pkg/database/commits.go @@ -0,0 +1,174 @@ +package database + +import ( + "time" + + . "github.com/drone/drone/pkg/model" + "github.com/russross/meddler" +) + +// Name of the Commit table in the database +const commitTable = "commits" + +// SQL Queries to retrieve a list of all Commits belonging to a Repo. +const commitStmt = ` +SELECT id, repo_id, status, started, finished, duration, +hash, branch, pull_request, author, gravatar, timestamp, message, created, updated +FROM commits +WHERE repo_id = ? AND branch = ? +ORDER BY created DESC +LIMIT 10 +` + +// SQL Queries to retrieve the latest Commit. +const commitLatestStmt = ` +SELECT id, repo_id, status, started, finished, duration, +hash, branch, pull_request, author, gravatar, timestamp, message, created, updated +FROM commits +WHERE repo_id = ? AND branch = ? +ORDER BY created DESC +LIMIT 1 +` + +// SQL Queries to retrieve a Commit by id. +const commitFindStmt = ` +SELECT id, repo_id, status, started, finished, duration, +hash, branch, pull_request, author, gravatar, timestamp, message, created, updated +FROM commits +WHERE id = ? +` + +// SQL Queries to retrieve a Commit by name and repo id. +const commitFindHashStmt = ` +SELECT id, repo_id, status, started, finished, duration, +hash, branch, pull_request, author, gravatar, timestamp, message, created, updated +FROM commits +WHERE hash = ? AND repo_id = ? +LIMIT 1 +` + +// SQL Query to retrieve a list of recent commits by user. +const userCommitRecentStmt = ` +SELECT r.slug, r.host, r.owner, r.name, +c.status, c.started, c.finished, c.duration, c.hash, c.branch, c.pull_request, +c.author, c.gravatar, c.timestamp, c.message, c.created, c.updated +FROM repos r, commits c +WHERE r.user_id = ? +AND r.team_id = 0 +AND r.id = c.repo_id +AND c.status IN ('Success', 'Failure') +ORDER BY c.created desc +LIMIT 10 +` + +// SQL Query to retrieve a list of recent commits by team. +const teamCommitRecentStmt = ` +SELECT r.slug, r.host, r.owner, r.name, +c.status, c.started, c.finished, c.duration, c.hash, c.branch, c.pull_request, +c.author, c.gravatar, c.timestamp, c.message, c.created, c.updated +FROM repos r, commits c +WHERE r.team_id = ? +AND r.id = c.repo_id +AND c.status IN ('Success', 'Failure') +ORDER BY c.created desc +LIMIT 10 +` + +// SQL Queries to delete a Commit. +const commitDeleteStmt = ` +DELETE FROM commits WHERE id = ? +` + +// SQL Queries to retrieve the latest Commits for each branch. +const commitBranchesStmt = ` +SELECT id, repo_id, status, started, finished, duration, +hash, branch, pull_request, author, gravatar, timestamp, message, created, updated +FROM commits +WHERE id IN ( + SELECT MAX(id) + FROM commits + WHERE repo_id = ? + GROUP BY branch) + ORDER BY branch ASC + ` + +// SQL Queries to retrieve the latest Commits for each branch. +const commitBranchStmt = ` +SELECT id, repo_id, status, started, finished, duration, +hash, branch, pull_request, author, gravatar, timestamp, message, created, updated +FROM commits +WHERE id IN ( + SELECT MAX(id) + FROM commits + WHERE repo_id = ? + AND branch = ? + GROUP BY branch) +LIMIT 1 + ` + +// Returns the Commit with the given ID. +func GetCommit(id int64) (*Commit, error) { + commit := Commit{} + err := meddler.QueryRow(db, &commit, commitFindStmt, id) + return &commit, err +} + +// Returns the Commit with the given hash. +func GetCommitHash(hash string, repo int64) (*Commit, error) { + commit := Commit{} + err := meddler.QueryRow(db, &commit, commitFindHashStmt, hash, repo) + return &commit, err +} + +// Returns the most recent Commit for the given branch. +func GetBranch(repo int64, branch string) (*Commit, error) { + commit := Commit{} + err := meddler.QueryRow(db, &commit, commitBranchStmt, repo, branch) + return &commit, err +} + +// Creates a new Commit. +func SaveCommit(commit *Commit) error { + if commit.ID == 0 { + commit.Created = time.Now().UTC() + } + commit.Updated = time.Now().UTC() + return meddler.Save(db, commitTable, commit) +} + +// Deletes an existing Commit. +func DeleteCommit(id int64) error { + _, err := db.Exec(commitDeleteStmt, id) + return err +} + +// Returns a list of all Commits associated +// with the specified Repo ID. +func ListCommits(repo int64, branch string) ([]*Commit, error) { + var commits []*Commit + err := meddler.QueryAll(db, &commits, commitStmt, repo, branch) + return commits, err +} + +// Returns a list of recent Commits associated +// with the specified User ID +func ListCommitsUser(user int64) ([]*RepoCommit, error) { + var commits []*RepoCommit + err := meddler.QueryAll(db, &commits, userCommitRecentStmt, user) + return commits, err +} + +// Returns a list of recent Commits associated +// with the specified Team ID +func ListCommitsTeam(team int64) ([]*RepoCommit, error) { + var commits []*RepoCommit + err := meddler.QueryAll(db, &commits, teamCommitRecentStmt, team) + return commits, err +} + +// Returns a list of the most recent commits for each branch. +func ListBranches(repo int64) ([]*Commit, error) { + var commits []*Commit + err := meddler.QueryAll(db, &commits, commitBranchesStmt, repo) + return commits, err +} diff --git a/pkg/database/database.go b/pkg/database/database.go new file mode 100644 index 000000000..223405433 --- /dev/null +++ b/pkg/database/database.go @@ -0,0 +1,24 @@ +package database + +import ( + "database/sql" + "log" + + "github.com/drone/drone/pkg/database/schema" +) + +// global instance of our database connection. +var db *sql.DB + +// Set sets the default database. +func Set(database *sql.DB) { + // set the global database + db = database + + // load the database schema. If this is + // a new database all the tables and + // indexes will be created. + if err := schema.Load(db); err != nil { + log.Fatal(err) + } +} diff --git a/pkg/database/encrypt/encrypt.go b/pkg/database/encrypt/encrypt.go new file mode 100644 index 000000000..f3f971c29 --- /dev/null +++ b/pkg/database/encrypt/encrypt.go @@ -0,0 +1,133 @@ +package encrypt + +import ( + "bytes" + "crypto/cipher" + "crypto/rand" + "encoding/gob" + "fmt" + "io" +) + +// EncryptedField handles encrypted and decryption of +// values to and from database columns. +type EncryptedField struct { + Cipher cipher.Block +} + +// PreRead is called before a Scan operation. It is given a pointer to +// the raw struct field, and returns the value that will be given to +// the database driver. +func (e *EncryptedField) PreRead(fieldAddr interface{}) (scanTarget interface{}, err error) { + // give a pointer to a byte buffer to grab the raw data + return new([]byte), nil +} + +// PostRead is called after a Scan operation. It is given the value returned +// by PreRead and a pointer to the raw struct field. It is expected to fill +// in the struct field if the two are different. +func (e *EncryptedField) PostRead(fieldAddr interface{}, scanTarget interface{}) error { + ptr := scanTarget.(*[]byte) + if ptr == nil { + return fmt.Errorf("encrypter.PostRead: nil pointer") + } + raw := *ptr + + // ignore fields that aren't set at all + if len(raw) == 0 { + return nil + } + + // decrypt value for gob decoding + var err error + raw, err = decrypt(e.Cipher, raw) + if err != nil { + return fmt.Errorf("Gob decryption error: %v", err) + } + + // decode gob + gobDecoder := gob.NewDecoder(bytes.NewReader(raw)) + if err := gobDecoder.Decode(fieldAddr); err != nil { + return fmt.Errorf("Gob decode error: %v", err) + } + + return nil +} + +// PreWrite is called before an Insert or Update operation. It is given +// a pointer to the raw struct field, and returns the value that will be +// given to the database driver. +func (e *EncryptedField) PreWrite(field interface{}) (saveValue interface{}, err error) { + buffer := new(bytes.Buffer) + + // gob encode + gobEncoder := gob.NewEncoder(buffer) + if err := gobEncoder.Encode(field); err != nil { + return nil, fmt.Errorf("Gob encoding error: %v", err) + } + // and then ecrypt + encrypted, err := encrypt(e.Cipher, buffer.Bytes()) + if err != nil { + return nil, fmt.Errorf("Gob decryption error: %v", err) + } + + return encrypted, nil +} + +// encrypt is a helper function to encrypt a slice +// of bytes using the specified block cipher. +func encrypt(block cipher.Block, v []byte) ([]byte, error) { + // if no block cipher value exists we'll assume + // the database is running in non-ecrypted mode. + if block == nil { + return v, nil + } + + value := make([]byte, len(v)) + copy(value, v) + + // Generate a random initialization vector + iv := generateRandomKey(block.BlockSize()) + if len(iv) != block.BlockSize() { + return nil, fmt.Errorf("Could not generate a valid initialization vector for encryption") + } + + // Encrypt it. + stream := cipher.NewCTR(block, iv) + stream.XORKeyStream(value, value) + + // Return iv + ciphertext. + return append(iv, value...), nil +} + +// decrypt is a helper function to decrypt a slice +// using the specified block cipher. +func decrypt(block cipher.Block, value []byte) ([]byte, error) { + // if no block cipher value exists we'll assume + // the database is running in non-ecrypted mode. + if block == nil { + return value, nil + } + + size := block.BlockSize() + if len(value) > size { + // Extract iv. + iv := value[:size] + // Extract ciphertext. + value = value[size:] + // Decrypt it. + stream := cipher.NewCTR(block, iv) + stream.XORKeyStream(value, value) + return value, nil + } + return nil, fmt.Errorf("Could not decrypt the value") +} + +// GenerateRandomKey creates a random key of size length bytes +func generateRandomKey(strength int) []byte { + k := make([]byte, strength) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + return nil + } + return k +} diff --git a/pkg/database/members.go b/pkg/database/members.go new file mode 100644 index 000000000..c6d6ebff3 --- /dev/null +++ b/pkg/database/members.go @@ -0,0 +1,86 @@ +package database + +import ( + . "github.com/drone/drone/pkg/model" + "github.com/russross/meddler" +) + +// Name of the Member table in the database +const memberTable = "members" + +// SQL Queries to retrieve a list of all members belonging to a team. +const memberStmt = ` +SELECT user_id, name, email, gravatar, role +FROM members, users +WHERE users.id = members.user_id +AND team_id = ? +` + +// SQL Queries to retrieve a team by id and user. +const memberFindStmt = ` +SELECT user_id, name, email, gravatar, role +FROM members, users +WHERE users.id = members.user_id +AND user_id = ? +AND team_id = ? +` + +// SQL Queries to retrieve a team by name . +const memberDeleteStmt = ` +DELETE FROM members +WHERE user_id = ? AND team_id = ? +` + +// SQL Queries to retrieve a member's role by id and user. +const roleFindStmt = ` +SELECT role FROM members +WHERE user_id = ? AND team_id = ? +` + +// Returns the Member with the given user and team IDs. +func GetMember(user, team int64) (*Member, error) { + member := Member{} + err := meddler.QueryRow(db, &member, memberFindStmt, user, team) + return &member, err +} + +// Returns true if the user is a member of the team +func IsMember(user, team int64) (bool, error) { + role := Role{} + err := meddler.QueryRow(db, &role, roleFindStmt, user, team) + return len(role.Role) > 0, err +} + +// Returns true is the user is an admin member of the team. +func IsMemberAdmin(user, team int64) (bool, error) { + role := Role{} + err := meddler.QueryRow(db, &role, roleFindStmt, user, team) + return role.Role == RoleAdmin || role.Role == RoleOwner, err +} + +// Creates a new Member. +func SaveMember(user, team int64, role string) error { + r := Role{} + if err := meddler.QueryRow(db, &r, roleFindStmt, user, team); err == nil { + r.Role = role + return meddler.Save(db, memberTable, &r) + } + + r.UserID = user + r.TeamID = team + r.Role = role + return meddler.Save(db, memberTable, &r) +} + +// Deletes an existing Member. +func DeleteMember(user, team int64) error { + _, err := db.Exec(memberDeleteStmt, user, team) + return err +} + +// Returns a list of all Team members. +func ListMembers(team int64) ([]*Member, error) { + var members []*Member + err := meddler.QueryAll(db, &members, memberStmt, team) + return members, err +} diff --git a/pkg/database/repos.go b/pkg/database/repos.go new file mode 100644 index 000000000..e4407c028 --- /dev/null +++ b/pkg/database/repos.go @@ -0,0 +1,92 @@ +package database + +import ( + "time" + + . "github.com/drone/drone/pkg/model" + "github.com/russross/meddler" +) + +// Name of the Repos table in the database +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 +FROM repos +WHERE user_id = ? AND team_id = 0 +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 +FROM repos +WHERE team_id = ? +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 +FROM repos +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 +FROM repos +WHERE slug = ? +` + +// Returns the Repo with the given ID. +func GetRepo(id int64) (*Repo, error) { + repo := Repo{} + err := meddler.QueryRow(db, &repo, repoFindStmt, id) + return &repo, err +} + +// Returns the Repo with the given slug. +func GetRepoSlug(slug string) (*Repo, error) { + repo := Repo{} + err := meddler.QueryRow(db, &repo, repoFindSlugStmt, slug) + return &repo, err +} + +// Creates a new Repository. +func SaveRepo(repo *Repo) error { + if repo.ID == 0 { + repo.Created = time.Now().UTC() + } + repo.Updated = time.Now().UTC() + return meddler.Save(db, repoTable, repo) +} + +// Deletes an existing Repository. +// TODO need to delete builds too. +func DeleteRepo(id int64) error { + _, err := db.Exec("DELETE FROM repos WHERE id = ?", id) + db.Exec("DELETE FROM commits WHERE repo_id = ?", id) + return err +} + +// Returns a list of all Repos associated +// with the specified User ID. +func ListRepos(id int64) ([]*Repo, error) { + var repos []*Repo + err := meddler.QueryAll(db, &repos, repoStmt, id) + return repos, err +} + +// Returns a list of all Repos associated +// with the specified Team ID. +func ListReposTeam(id int64) ([]*Repo, error) { + var repos []*Repo + err := meddler.QueryAll(db, &repos, repoTeamStmt, id) + return repos, err +} diff --git a/pkg/database/schema/sample.sql b/pkg/database/schema/sample.sql new file mode 100644 index 000000000..6b3f6f297 --- /dev/null +++ b/pkg/database/schema/sample.sql @@ -0,0 +1,126 @@ +DELETE FROM builds; +DELETE FROM commits; +DELETE FROM repos; +DELETE FROM members; +DELETE FROM teams; +DELETE FROM users; +DELETE FROM settings; + +-- insert users (default password is "password") +INSERT INTO users values (1, 'brad.rydzewski@gmail.com' , '$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS', 'nPmsbl6YNLUIUo0I7gkMcQ' ,'Brad Rydzewski', '8c58a0be77ee441bb8f8595b7f1b4e87', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, '', '', '', '', ''); +INSERT INTO users values (2, 'thomas.d.burke@gmail.com' , '$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS', 'sal5Tzy6S10yZCaE0jl6QA', 'Thomas Burke', 'c62f7126273f7fa786274274a5dec8ce', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, '', '', '', '', ''); +INSERT INTO users values (3, 'carlos.morales.duran@gmail.com', '$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS', 'bq87o8AmDUOahKApEy2tVQ', 'Carlos Morales', 'c2180a539620d90d68eaeb848364f1c2', '2013-09-16 00:00:00', '2013-09-17 00:00:00', 1, '', '', '', '', ''); + +-- insert teams +insert into teams values (1, 'drone', 'Drone' , 'brad@drone.io' , '0057e90a8036c29b1ddb22d0fd08b72c', '2013-09-16 00:00:00', '2013-09-16 00:00:00'); +insert into teams values (2, 'google', 'Google', 'dev@google.com' , '24ba30616d2a20673f54c2aee36d159e', '2013-09-16 00:00:00', '2013-09-16 00:00:00'); +insert into teams values (3, 'gradle', 'Gradle', 'dev@gradle.com' , '5cc3b557e3a3978d52036da9a5be2a08', '2013-09-16 00:00:00', '2013-09-16 00:00:00'); +insert into teams values (4, 'dart', 'Dart' , 'dev@dartlang.org', 'f41fe13f979f2f93cc8b971e1875bdf8', '2013-09-16 00:00:00', '2013-09-16 00:00:00'); + +-- insert team members +insert into members values (1, 1, 1, 'Owner'); +insert into members values (2, 1, 2, 'Admin'); +insert into members values (3, 1, 3, 'Write'); + +-- insert repository +insert into repos values (1, 'github.com/drone/jkl', 'github.com', 'drone', 'jkl', 0, 0, 0, 0, 900, 'git', 'git://github.com/drone/jkl.git', '', '', '', '', '', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, 1); +insert into repos values (2, 'github.com/drone/drone', 'github.com', 'drone', 'drone', 1, 0, 0, 0, 900, 'git', 'git@github.com:drone/drone.git', '', '', '', '', '', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, 1); +insert into repos values (3, 'github.com/bradrydzewski/drone', 'github.com', 'bradrydzewski', 'drone', 1, 0, 0, 0, 900, 'git', 'git@github.com:bradrydzewski/drone.git', '', '', '', '', '', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, 1); +insert into repos values (4, 'github.com/bradrydzewski/blog', 'github.com', 'bradrydzewski', 'blog', 0, 0, 0, 0, 900, 'git', 'git://github.com/bradrydzewski/blog.git', '', '', '', '', '', '2013-09-16 00:00:00', '2013-09-16 00:00:00', 1, 0); + +-- insert commits + +insert into commits values (1, 1, 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, 'ef2221722e6f07a6eaf8af8907b45324428a891d', 'master', '','brad.rydzewski@gmail.com', '8c58a0be77ee441bb8f8595b7f1b4e87', '2013-09-16 00:00:00', 'Fixed mock db class for entity', '2013-09-16 00:00:00', '2013-09-16 00:00:00'); +insert into commits values (2, 1, 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '867477aa487d01df28522cee84cd06f5aa154e53', 'master', '','brad.rydzewski@gmail.com', '8c58a0be77ee441bb8f8595b7f1b4e87', '2013-09-16 00:00:00', 'Fixed mock db class for entity', '2013-09-16 00:00:00', '2013-09-16 00:00:00'); +insert into commits values (3, 1, 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, 'e43427ab462417cb3d53b8702c298c1675deb926', 'master', '','brad.rydzewski@gmail.com', '8c58a0be77ee441bb8f8595b7f1b4e87', '2013-09-16 00:00:00', 'Save deleted entity data to database', '2013-09-16 00:00:00', '2013-09-16 00:00:00'); +insert into commits values (4, 1, 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, 'a43427ab462417cb3d53b8702c298c1675deb926', 'dev', '','brad.rydzewski@gmail.com', '8c58a0be77ee441bb8f8595b7f1b4e87', '2013-09-16 00:00:00', 'Save deleted entity data to database', '2013-09-16 00:00:00', '2013-09-16 00:00:00'); + +-- insert builds + +insert into builds values (1, 1, 'node_0.10', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', ''); +insert into builds values (2, 1, 'node_0.90', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', ''); +insert into builds values (3, 1, 'node_0.80', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', ''); +insert into builds values (4, 2, 'node_0.10', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', ''); +insert into builds values (5, 2, 'node_0.90', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', ''); +insert into builds values (6, 2, 'node_0.80', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', ''); +insert into builds values (7, 3, 'node_0.10', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', ''); +insert into builds values (8, 3, 'node_0.90', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', ''); +insert into builds values (9, 3, 'node_0.80', 'Success', '2013-09-16 00:00:00','2013-09-16 00:00:00', 60, '2013-09-16 00:00:00','2013-09-16 00:00:00', ''); + +-- insert default, dummy settings + +insert into settings values (1,'','','','','','','','','','localhost:8080','http'); + +-- add public & private keys to all repositories + +update repos set public_key = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCybgl9+Y0VY0mKng3AB3CwCMAOVvg+Xh4X/4lP7SR815GaeEJQusaA0p33HkZfS/2XREWYMtiopHP0bZuBIht76JdhrJlHh1AcLoPQvWJROFvRGol6igVEVZzs9sUdZaPrexFz1CS/j6BJFzPsHnL4gXT3s4PYYST9++pThI90Aw=='; + +update repos set private_key = '-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQCybgl9+Y0VY0mKng3AB3CwCMAOVvg+Xh4X/4lP7SR815GaeEJQ +usaA0p33HkZfS/2XREWYMtiopHP0bZuBIht76JdhrJlHh1AcLoPQvWJROFvRGol6 +igVEVZzs9sUdZaPrexFz1CS/j6BJFzPsHnL4gXT3s4PYYST9++pThI90AwIDAQAB +AoGAaxvs7MdaLsWcRu7cGDMfLT0DdVg1ytKaxBMsrWMQrTSGfjDEtkt4j6pfExIE +cn5ea2ibUmLrdkjKJqeJWrpLvlOZGhahBcL/SueFOfr6Lm+m8LvlTrX6JhyLXpx5 +NbeEFr0mN16PC6JqkN0xRCN9BfV9m6gnpuP/ojD3RKYMZtkCQQDFbSX/ddEfp9ME +vRNAYif+bFxI6PEgMmwrCIjJGHOsq7zba3Z7KWjW034x2rJ3Cbhs8xtyTcA5qy9F +OzL3pFs3AkEA514SUXowIiqjh6ypnSvUBaQZsWjexDxTXN09DTYPt+Ck1qdzTHWP +9nerg2G3B6bTOWZBftHMaZ/plZ/eyV0LlQJACU1rTO4wPF2cA80k6xO07rgMYSMY +uXumvSBZ0Z/lU22EKJKXspXw6q5sc8zqO9GpbvjFgk1HkXAPeiOf8ys7YQJAD1CI +wd/mo7xSyr5BE+g8xorQMJASfsbHddQnIGK9s5wpDRRUa3E0sEnHjpC/PsBqJth/ +6VcVwsAVBBRq+MUx6QJAS9KKxKcMf8JpnDheV7jh+WJKckabA1L2bq8sN6kXfPn0 +o7deiE1FKJizXKJ6gd6anfuG3m7VAs7wJhzc685yMg== +-----END RSA PRIVATE KEY-----'; + +-- add standard output to all builds + +update builds set stdout = '$ mvn test +------------------------------------------------------- + T E S T S +------------------------------------------------------- +Running brooklyn.qa.longevity.MonitorUtilsTest +Configuring TestNG with: TestNG652Configurator +[GC 69952K->6701K(253440K), 0.0505760 secs] +2013-08-21 21:12:58,327 INFO TESTNG RUNNING: Suite: "Command line test" containing "7" Tests (config: null) +2013-08-21 21:12:58,342 INFO BrooklynLeakListener.onStart attempting to terminate all extant ManagementContexts: name=Command line test; includedGroups=[]; excludedGroups=[Integration, Acceptance, Live, WIP]; suiteName=brooklyn.qa.longevity.MonitorUtilsTest; outDir=/scratch/jenkins/workspace/brooklyncentral/brooklyn/usage/qa/target/surefire-reports/brooklyn.qa.longevity.MonitorUtilsTest +2013-08-21 21:12:58,473 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testFindOwnPid() +2013-08-21 21:12:58,939 INFO executing cmd: ps -p 7484 +2013-08-21 21:12:59,030 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testFindOwnPid() finished in 595 ms +2013-08-21 21:12:59,033 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testGetRunningPids() +2013-08-21 21:12:59,035 INFO executing cmd: ps ax +2013-08-21 21:12:59,137 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testGetRunningPids() finished in 104 ms +2013-08-21 21:12:59,139 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testGroovyExecuteAndWaitForConsumingOutputStream() +2013-08-21 21:12:59,295 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testGroovyExecuteAndWaitForConsumingOutputStream() finished in 155 ms +2013-08-21 21:12:59,298 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testIsPidRunning() +2013-08-21 21:12:59,300 INFO executing cmd: ps ax +2013-08-21 21:12:59,384 INFO executing cmd: ps -p 7484 +2013-08-21 21:12:59,391 INFO executing cmd: ps -p 10000 +2013-08-21 21:12:59,443 INFO pid 10000 not running: +2013-08-21 21:12:59,446 INFO executing cmd: ps -p 1234567 +2013-08-21 21:12:59,455 INFO pid 1234567 not running: +2013-08-21 21:12:59,456 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testIsPidRunning() finished in 158 ms +2013-08-21 21:12:59,481 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testIsUrlUp() +[GC 76653K->7013K(253440K), 0.0729880 secs] +2013-08-21 21:13:00,726 INFO Error reading URL http://localhost/thispathdoesnotexist: org.apache.http.conn.HttpHostConnectException: Connection to http://localhost refused +2013-08-21 21:13:00,727 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testIsUrlUp() finished in 1246 ms +2013-08-21 21:13:00,760 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testMemoryUsage() +2013-08-21 21:13:00,762 INFO executing cmd: jmap -histo 7484 +2013-08-21 21:13:02,275 INFO executing cmd: jmap -histo 7484 +2013-08-21 21:13:03,690 INFO executing cmd: jmap -histo 7484 +2013-08-21 21:13:04,725 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testMemoryUsage() finished in 3965 ms +2013-08-21 21:13:04,752 INFO TESTNG INVOKING: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testSearchLog() +2013-08-21 21:13:04,816 INFO executing cmd: grep -E line1 /tmp/monitorUtilsTest.testSearchLog2369184699231420767.txt +2013-08-21 21:13:04,848 INFO executing cmd: grep -E line1|line2 /tmp/monitorUtilsTest.testSearchLog2369184699231420767.txt +2013-08-21 21:13:04,854 INFO executing cmd: grep -E textnotthere /tmp/monitorUtilsTest.testSearchLog2369184699231420767.txt +2013-08-21 21:13:04,858 INFO executing cmd: grep -E line /tmp/monitorUtilsTest.testSearchLog2369184699231420767.txt +2013-08-21 21:13:04,897 INFO TESTNG PASSED: "Command line test" - brooklyn.qa.longevity.MonitorUtilsTest.testSearchLog() finished in 145 ms +2013-08-21 21:13:04,917 INFO TESTNG +=============================================== + Command line test + Tests run: 7, Failures: 0, Skips: 0 +=============================================== +2013-08-21 21:13:04,944 INFO BrooklynLeakListener.onFinish attempting to terminate all extant ManagementContexts: name=Command line test; includedGroups=[]; excludedGroups=[Integration, Acceptance, Live, WIP]; suiteName=brooklyn.qa.longevity.MonitorUtilsTest; outDir=/scratch/jenkins/workspace/brooklyncentral/brooklyn/usage/qa/target/surefire-reports/brooklyn.qa.longevity.MonitorUtilsTest +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 diff --git a/pkg/database/schema/schema.go b/pkg/database/schema/schema.go new file mode 100644 index 000000000..993db07e2 --- /dev/null +++ b/pkg/database/schema/schema.go @@ -0,0 +1,198 @@ +package schema + +import ( + "database/sql" +) + +// SQL statement to create the User Table. +var userTableStmt = ` +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,email VARCHAR(255) UNIQUE + ,password VARCHAR(255) + ,token VARCHAR(255) UNIQUE + ,name VARCHAR(255) + ,gravatar VARCHAR(255) + ,created TIMESTAMP + ,updated TIMESTAMP + ,admin BOOLEAN + ,github_login VARCHAR(255) + ,github_token VARCHAR(255) + ,bitbucket_login VARCHAR(255) + ,bitbucket_token VARCHAR(255) + ,bitbucket_secret VARCHAR(255) +); +` + +// SQL statement to create the Team Table. +var teamTableStmt = ` +CREATE TABLE teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,slug VARCHAR(255) UNIQUE + ,name VARCHAR(255) + ,email VARCHAR(255) + ,gravatar VARCHAR(255) + ,created TIMESTAMP + ,updated TIMESTAMP +); +` + +// SQL statement to create the Member Table. +var memberTableStmt = ` +CREATE TABLE members ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,team_id INTEGER + ,user_id INTEGER + ,role INTEGER +); +` + +// SQL statement to create the Repo Table. +var repoTableStmt = ` +CREATE TABLE repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,slug VARCHAR(1024) UNIQUE + ,host VARCHAR(255) + ,owner VARCHAR(255) + ,name VARCHAR(255) + ,private BOOLEAN + ,disabled BOOLEAN + ,disabled_pr BOOLEAN + ,priveleged BOOLEAN + ,timeout INTEGER + ,scm VARCHAR(25) + ,url VARCHAR(1024) + ,username VARCHAR(255) + ,password VARCHAR(255) + ,public_key VARCHAR(1024) + ,private_key VARCHAR(1024) + ,params VARCHAR(2000) + ,created TIMESTAMP + ,updated TIMESTAMP + ,user_id INTEGER + ,team_id INTEGER +); +` + +// SQL statement to create the Commit Table. +var commitTableStmt = ` +CREATE TABLE commits ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,repo_id INTEGER + ,status VARCHAR(255) + ,started TIMESTAMP + ,finished TIMESTAMP + ,duration INTEGER + ,attempts INTEGER + ,hash VARCHAR(255) + ,branch VARCHAR(255) + ,pull_request VARCHAR(255) + ,author VARCHAR(255) + ,gravatar VARCHAR(255) + ,timestamp VARCHAR(255) + ,message VARCHAR(255) + ,created TIMESTAMP + ,updated TIMESTAMP +); +` + +// SQL statement to create the Build Table. +var buildTableStmt = ` +CREATE TABLE builds ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,commit_id INTEGER + ,slug VARCHAR(255) + ,status VARCHAR(255) + ,started TIMESTAMP + ,finished TIMESTAMP + ,duration INTEGER + ,created TIMESTAMP + ,updated TIMESTAMP + ,stdout BLOB +); +` + +// SQL statement to create the Settings +var settingsTableStmt = ` +CREATE TABLE settings ( + id INTEGER PRIMARY KEY + ,github_key VARCHAR(255) + ,github_secret VARCHAR(255) + ,bitbucket_key VARCHAR(255) + ,bitbucket_secret VARCHAR(255) + ,smtp_server VARCHAR(1024) + ,smtp_port VARCHAR(5) + ,smtp_address VARCHAR(1024) + ,smtp_username VARCHAR(1024) + ,smtp_password VARCHAR(1024) + ,hostname VARCHAR(1024) + ,scheme VARCHAR(5) +); +` + +var memberUniqueIndex = ` +CREATE UNIQUE INDEX member_uix ON members (team_id, user_id); +` + +var memberTeamIndex = ` +CREATE INDEX member_team_ix ON members (team_id); +` + +var memberUserIndex = ` +CREATE INDEX member_user_ix ON members (user_id); +` + +var commitUniqueIndex = ` +CREATE UNIQUE INDEX commits_uix ON commits (repo_id, hash, branch); +` + +var commitRepoIndex = ` +CREATE INDEX commits_repo_ix ON commits (repo_id); +` + +var commitBranchIndex = ` +CREATE INDEX commits_repo_ix ON commits (repo_id, branch); +` + +var repoTeamIndex = ` +CREATE INDEX repo_team_ix ON repos (team_id); +` + +var repoUserIndex = ` +CREATE INDEX repo_user_ix ON repos (user_id); +` + +var buildCommitIndex = ` +CREATE INDEX builds_commit_ix ON builds (commit_id); +` + +var buildSlugIndex = ` +CREATE INDEX builds_commit_slug_ix ON builds (commit_id, slug); +` + +// Load will apply the DDL commands to +// the provided database. +func Load(db *sql.DB) error { + + // created tables + db.Exec(userTableStmt) + db.Exec(teamTableStmt) + db.Exec(memberTableStmt) + db.Exec(repoTableStmt) + db.Exec(commitTableStmt) + db.Exec(buildTableStmt) + db.Exec(settingsTableStmt) + + db.Exec(memberUniqueIndex) + db.Exec(memberTeamIndex) + db.Exec(memberUserIndex) + db.Exec(commitUniqueIndex) + db.Exec(commitRepoIndex) + db.Exec(commitBranchIndex) + db.Exec(repoTeamIndex) + db.Exec(repoUserIndex) + db.Exec(buildCommitIndex) + db.Exec(buildSlugIndex) + + return nil +} diff --git a/pkg/database/schema/schema.sql b/pkg/database/schema/schema.sql new file mode 100644 index 000000000..950458508 --- /dev/null +++ b/pkg/database/schema/schema.sql @@ -0,0 +1,127 @@ +DROP TABLE IF EXISTS builds; +DROP TABLE IF EXISTS commits; +DROP TABLE IF EXISTS repos; +DROP TABLE IF EXISTS members; +DROP TABLE IF EXISTS teams; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS settings; + +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,email VARCHAR(255) UNIQUE + ,password VARCHAR(255) + ,token VARCHAR(255) UNIQUE + ,name VARCHAR(255) + ,gravatar VARCHAR(255) + ,created TIMESTAMP + ,updated TIMESTAMP + ,admin BOOLEAN + + ,github_login VARCHAR(255) + ,github_token VARCHAR(255) + + ,bitbucket_login VARCHAR(255) + ,bitbucket_token VARCHAR(255) + ,bitbucket_secret VARCHAR(255) +); + +CREATE TABLE teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,slug VARCHAR(255) UNIQUE + ,name VARCHAR(255) UNIQUE + ,email VARCHAR(255) + ,gravatar VARCHAR(255) + ,created TIMESTAMP + ,updated TIMESTAMP +); + +CREATE TABLE members ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,team_id INTEGER + ,user_id INTEGER + ,role INTEGER +); + +CREATE TABLE repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,slug VARCHAR(1024) UNIQUE + ,host VARCHAR(255) + ,owner VARCHAR(255) + ,name VARCHAR(255) + ,private BOOLEAN + ,disabled BOOLEAN + ,disabled_pr BOOLEAN + ,priveleged BOOLEAN + ,timeout INTEGER + + ,scm VARCHAR(25) + ,url VARCHAR(1024) + ,username VARCHAR(255) + ,password VARCHAR(255) + ,public_key VARCHAR(1024) + ,private_key VARCHAR(1024) + ,params VARCHAR(2000) + + ,created TIMESTAMP + ,updated TIMESTAMP + ,user_id INTEGER + ,team_id INTEGER +); + +CREATE TABLE commits ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,repo_id INTEGER + ,status VARCHAR(255) + ,started TIMESTAMP + ,finished TIMESTAMP + ,duration INTEGER + ,hash VARCHAR(255) + ,branch VARCHAR(255) + ,pull_request VARCHAR(255) + ,author VARCHAR(255) + ,gravatar VARCHAR(255) + ,timestamp VARCHAR(255) + ,message VARCHAR(255) + ,created TIMESTAMP + ,updated TIMESTAMP +); + +CREATE TABLE builds ( + id INTEGER PRIMARY KEY AUTOINCREMENT + ,commit_id INTEGER + ,slug VARCHAR(255) + ,status VARCHAR(255) + ,started TIMESTAMP + ,finished TIMESTAMP + ,duration INTEGER + ,created TIMESTAMP + ,updated TIMESTAMP + ,stdout BLOB +); + +CREATE TABLE settings ( + id INTEGER PRIMARY KEY + ,github_key VARCHAR(255) + ,github_secret VARCHAR(255) + ,bitbucket_key VARCHAR(255) + ,bitbucket_secret VARCHAR(255) + ,smtp_server VARCHAR(1024) + ,smtp_port VARCHAR(5) + ,smtp_address VARCHAR(1024) + ,smtp_username VARCHAR(1024) + ,smtp_password VARCHAR(1024) + ,hostname VARCHAR(1024) + ,scheme VARCHAR(5) +); + +CREATE UNIQUE INDEX member_uix ON members (team_id, user_id); +CREATE UNIQUE INDEX commits_uix ON commits (repo_id, hash, branch); + +CREATE INDEX member_team_ix ON members (team_id); +CREATE INDEX member_user_ix ON members (user_id); +CREATE INDEX repo_team_ix ON repos (team_id); +CREATE INDEX repo_user_ix ON repos (user_id); +CREATE INDEX commits_repo_ix ON commits (repo_id); +CREATE INDEX commits_repo_branch_ix ON commits (repo_id, branch); +CREATE INDEX builds_commit_ix ON builds (commit_id); +CREATE INDEX builds_commit_slug_ix ON builds (commit_id, slug); diff --git a/pkg/database/settings.go b/pkg/database/settings.go new file mode 100644 index 000000000..0e210c991 --- /dev/null +++ b/pkg/database/settings.go @@ -0,0 +1,72 @@ +package database + +import ( + . "github.com/drone/drone/pkg/model" + "github.com/russross/meddler" +) + +// Name of the Settings table in the database +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 +FROM settings WHERE id = 1 +` + +//var ( +// // mutex for locking the local settings cache +// settingsLock sync.Mutex +// +// // cached settings +// settingsCache = &Settings{} +//) + +// Returns the system Settings. +func GetSettings() (*Settings, error) { + //settingsLock.Lock() + //defer settingsLock.Unlock() + + // return a copy of the settings + //if settingsCache.ID == 0 { + /// settingsCopy := &Settings{} + // *settingsCopy = *settingsCache + // return settingsCopy, nil + //} + + settings := Settings{} + err := meddler.QueryRow(db, &settings, settingsStmt) + //if err == sql.ErrNoRows { + // // we ignore the NoRows error in case this + // // is the first time the system is being used + // err = nil + //} + return &settings, err +} + +// Returns the system Settings. This is expected +// always pass, and will panic on failure. +func SettingsMust() *Settings { + settings, err := GetSettings() + if err != nil { + panic(err) + } + return settings +} + +// Saves the system Settings. +func SaveSettings(settings *Settings) error { + //settingsLock.Lock() + //defer settingsLock.Unlock() + + // persist changes to settings + err := meddler.Save(db, settingsTable, settings) + if err != nil { + return err + } + + // store updated settings in cache + //*settingsCache = *settings + return nil +} diff --git a/pkg/database/teams.go b/pkg/database/teams.go new file mode 100644 index 000000000..9ee479bbd --- /dev/null +++ b/pkg/database/teams.go @@ -0,0 +1,73 @@ +package database + +import ( + "time" + + . "github.com/drone/drone/pkg/model" + "github.com/russross/meddler" +) + +// Name of the Team table in the database +const teamTable = "teams" + +// SQL Queries to retrieve a list of all teams belonging to a user. +const teamStmt = ` +SELECT id, slug, name, email, gravatar, created, updated +FROM teams +WHERE id IN (select team_id from members where user_id = ?) +` + +// SQL Queries to retrieve a team by id and user. +const teamFindStmt = ` +SELECT id, slug, name, email, gravatar, created, updated +FROM teams +WHERE id = ? +` + +// SQL Queries to retrieve a team by slug. +const teamFindSlugStmt = ` +SELECT id, slug, name, email, gravatar, created, updated +FROM teams +WHERE slug = ? +` + +// Returns the Team with the given ID. +func GetTeam(id int64) (*Team, error) { + team := Team{} + err := meddler.QueryRow(db, &team, teamFindStmt, id) + return &team, err +} + +// Returns the Team with the given slug. +func GetTeamSlug(slug string) (*Team, error) { + team := Team{} + err := meddler.QueryRow(db, &team, teamFindSlugStmt, slug) + return &team, err +} + +// Saves a Team. +func SaveTeam(team *Team) error { + if team.ID == 0 { + team.Created = time.Now().UTC() + } + team.Updated = time.Now().UTC() + return meddler.Save(db, teamTable, team) +} + +// Deletes an existing Team account. +func DeleteTeam(id int64) error { + // disassociate all repos with this team + db.Exec("UPDATE repos SET team_id = 0 WHERE team_id = ?", id) + // delete the team memberships and the team itself + db.Exec("DELETE FROM members WHERE team_id = ?", id) + db.Exec("DELETE FROM teams WHERE id = ?", id) + return nil +} + +// Returns a list of all Teams associated +// with the specified User ID. +func ListTeams(id int64) ([]*Team, error) { + var teams []*Team + err := meddler.QueryAll(db, &teams, teamStmt, id) + return teams, err +} diff --git a/pkg/database/testing/builds_test.go b/pkg/database/testing/builds_test.go new file mode 100644 index 000000000..3dd6bb4db --- /dev/null +++ b/pkg/database/testing/builds_test.go @@ -0,0 +1,136 @@ +package database + +import ( + "testing" + + "github.com/drone/drone/pkg/database" +) + +func TestGetBuild(t *testing.T) { + Setup() + defer Teardown() + + build, err := database.GetBuild(1) + if err != nil { + t.Error(err) + } + + if build.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, build.ID) + } + + if build.Slug != "node_0.10" { + t.Errorf("Exepected Slug %s, got %s", "node_0.10", build.Slug) + } + + if build.Status != "Success" { + t.Errorf("Exepected Status %s, got %s", "Success", build.Status) + } +} + +func TestGetBuildSlug(t *testing.T) { + Setup() + defer Teardown() + + build, err := database.GetBuildSlug("node_0.10", 1) + if err != nil { + t.Error(err) + } + + if build.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, build.ID) + } + + if build.Slug != "node_0.10" { + t.Errorf("Exepected Slug %s, got %s", "node_0.10", build.Slug) + } + + if build.Status != "Success" { + t.Errorf("Exepected Status %s, got %s", "Success", build.Status) + } +} + +func TestSaveBbuild(t *testing.T) { + Setup() + defer Teardown() + + // get the build we plan to update + build, err := database.GetBuild(1) + if err != nil { + t.Error(err) + } + + // update fields + build.Status = "Failing" + + // update the database + if err := database.SaveBuild(build); err != nil { + t.Error(err) + } + + // get the updated build + updatedBuild, err := database.GetBuild(1) + if err != nil { + t.Error(err) + } + + if build.ID != updatedBuild.ID { + t.Errorf("Exepected ID %d, got %d", updatedBuild.ID, build.ID) + } + + if build.Slug != updatedBuild.Slug { + t.Errorf("Exepected Slug %s, got %s", updatedBuild.Slug, build.Slug) + } + + if build.Status != updatedBuild.Status { + t.Errorf("Exepected Status %s, got %s", updatedBuild.Status, build.Status) + } +} + +func TestDeleteBuild(t *testing.T) { + Setup() + defer Teardown() + + if err := database.DeleteBuild(1); err != nil { + t.Error(err) + } + + // try to get the deleted row + _, err := database.GetBuild(1) + if err == nil { + t.Fail() + } +} + +func TestListBuilds(t *testing.T) { + Setup() + defer Teardown() + + // builds for commit_id = 1 + builds, err := database.ListBuilds(1) + if err != nil { + t.Error(err) + } + + // verify user count + if len(builds) != 2 { + t.Errorf("Exepected %d builds in database, got %d", 2, len(builds)) + return + } + + // get the first user in the list and verify + // fields are being populated correctly + build := builds[1] + + if build.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, build.ID) + } + + if build.Slug != "node_0.10" { + t.Errorf("Exepected Slug %s, got %s", "node_0.10", build.Slug) + } + + if build.Status != "Success" { + t.Errorf("Exepected Status %s, got %s", "Success", build.Status) + } +} diff --git a/pkg/database/testing/commits_test.go b/pkg/database/testing/commits_test.go new file mode 100644 index 000000000..1f6b8b9de --- /dev/null +++ b/pkg/database/testing/commits_test.go @@ -0,0 +1,164 @@ +package database + +import ( + "testing" + + "github.com/drone/drone/pkg/database" +) + +func TestGetCommit(t *testing.T) { + Setup() + defer Teardown() + + commit, err := database.GetCommit(1) + if err != nil { + t.Error(err) + } + + if commit.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, commit.ID) + } + + if commit.Status != "Success" { + t.Errorf("Exepected Status %s, got %s", "Success", commit.Status) + } + + if commit.Hash != "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608" { + t.Errorf("Exepected Hash %s, got %s", "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608", commit.Hash) + } + + if commit.Branch != "master" { + t.Errorf("Exepected Branch %s, got %s", "master", commit.Branch) + } + + if commit.Author != "brad.rydzewski@gmail.com" { + t.Errorf("Exepected Author %s, got %s", "master", commit.Author) + } + + if commit.Message != "commit message" { + t.Errorf("Exepected Message %s, got %s", "master", commit.Message) + } + + if commit.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" { + t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", commit.Gravatar) + } +} + +func TestGetCommitHash(t *testing.T) { + Setup() + defer Teardown() + + commit, err := database.GetCommitHash("4f4c4594be6d6ddbc1c0dd521334f7ecba92b608", 1) + if err != nil { + t.Error(err) + } + + if commit.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, commit.ID) + } + + if commit.Hash != "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608" { + t.Errorf("Exepected Hash %s, got %s", "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608", commit.Hash) + } + + if commit.Status != "Success" { + t.Errorf("Exepected Status %s, got %s", "Success", commit.Status) + } +} + +func TestSaveCommit(t *testing.T) { + Setup() + defer Teardown() + + // get the commit we plan to update + commit, err := database.GetCommit(1) + if err != nil { + t.Error(err) + } + + // update fields + commit.Status = "Failing" + + // update the database + if err := database.SaveCommit(commit); err != nil { + t.Error(err) + } + + // get the updated commit + updatedCommit, err := database.GetCommit(1) + if err != nil { + t.Error(err) + } + + if commit.Hash != updatedCommit.Hash { + t.Errorf("Exepected Hash %s, got %s", updatedCommit.Hash, commit.Hash) + } + + if commit.Status != "Failing" { + t.Errorf("Exepected Status %s, got %s", updatedCommit.Status, commit.Status) + } +} + +func TestDeleteCommit(t *testing.T) { + Setup() + defer Teardown() + + if err := database.DeleteCommit(1); err != nil { + t.Error(err) + } + + // try to get the deleted row + _, err := database.GetCommit(1) + if err == nil { + t.Fail() + } +} + +func TestListCommits(t *testing.T) { + Setup() + defer Teardown() + + // commits for repo_id = 1 + commits, err := database.ListCommits(1, "master") + if err != nil { + t.Error(err) + } + + // verify commit count + if len(commits) != 2 { + t.Errorf("Exepected %d commits in database, got %d", 2, len(commits)) + return + } + + // get the first user in the list and verify + // fields are being populated correctly + commit := commits[1] // TODO something strange is happening with ordering here + + if commit.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, commit.ID) + } + + if commit.Status != "Success" { + t.Errorf("Exepected Status %s, got %s", "Success", commit.Status) + } + + if commit.Hash != "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608" { + t.Errorf("Exepected Hash %s, got %s", "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608", commit.Hash) + } + + if commit.Branch != "master" { + t.Errorf("Exepected Branch %s, got %s", "master", commit.Branch) + } + + if commit.Author != "brad.rydzewski@gmail.com" { + t.Errorf("Exepected Author %s, got %s", "master", commit.Author) + } + + if commit.Message != "commit message" { + t.Errorf("Exepected Message %s, got %s", "master", commit.Message) + } + + if commit.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" { + t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", commit.Gravatar) + } +} diff --git a/pkg/database/testing/members_test.go b/pkg/database/testing/members_test.go new file mode 100644 index 000000000..aeebcf98a --- /dev/null +++ b/pkg/database/testing/members_test.go @@ -0,0 +1,140 @@ +package database + +import ( + "testing" + + "github.com/drone/drone/pkg/database" + "github.com/drone/drone/pkg/model" +) + +// TODO unit test to verify unique constraint on Team.Name + +// TestGetMember tests the ability to retrieve a Team +// Member from the database by Unique ID. +func TestGetMember(t *testing.T) { + Setup() + defer Teardown() + + // get member by user_id and team_id + member, err := database.GetMember(1, 1) + if err != nil { + t.Error(err) + } + + if member.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, member.ID) + } + + if member.Name != "Brad Rydzewski" { + t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", member.Name) + } + + if member.Email != "brad.rydzewski@gmail.com" { + t.Errorf("Exepected Email %s, got %s", "brad.rydzewski@gmail.com", member.Email) + } + + if member.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" { + t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", member.Gravatar) + } + + if member.Role != model.RoleOwner { + t.Errorf("Exepected Role %s, got %s", model.RoleOwner, member.Role) + } +} + +func TestIsMember(t *testing.T) { + Setup() + defer Teardown() + + ok, err := database.IsMember(1, 1) + if err != nil { + t.Error(err) + } + + if !ok { + t.Errorf("Expected IsMember to return true, returned false") + } +} + +func TestIsMemberAdmin(t *testing.T) { + Setup() + defer Teardown() + + // expecting user is Owner + if ok, err := database.IsMemberAdmin(1, 1); err != nil { + t.Error(err) + } else if !ok { + t.Errorf("Expected IsMemberAdmin to return true, returned false") + } + + // expecting user is Admin + if ok, err := database.IsMemberAdmin(2, 1); err != nil { + t.Error(err) + } else if !ok { + t.Errorf("Expected IsMemberAdmin to return true, returned false") + } + + // expecting user is NOT Admin (Write role) + if ok, err := database.IsMemberAdmin(3, 1); err != nil { + t.Error(err) + } else if ok { + t.Errorf("Expected IsMemberAdmin to return false, returned true") + } +} + +func TestDeleteMember(t *testing.T) { + Setup() + defer Teardown() + + // delete member by user_id and team_id + if err := database.DeleteMember(1, 1); err != nil { + t.Error(err) + } + + // get member by user_id and team_id + if _, err := database.GetMember(1, 1); err == nil { + t.Error(err) + } + +} + +func TestListMembers(t *testing.T) { + Setup() + defer Teardown() + + // list members by team_id + members, err := database.ListMembers(1) + if err != nil { + t.Error(err) + } + + // verify team count + if len(members) != 3 { + t.Errorf("Exepected %d Team Members in database, got %d", 3, len(members)) + return + } + + // get the first member in the list and verify + // fields are being populated correctly + member := members[0] + + if member.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, member.ID) + } + + if member.Name != "Brad Rydzewski" { + t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", member.Name) + } + + if member.Email != "brad.rydzewski@gmail.com" { + t.Errorf("Exepected Email %s, got %s", "brad.rydzewski@gmail.com", member.Email) + } + + if member.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" { + t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", member.Gravatar) + } + + if member.Role != model.RoleOwner { + t.Errorf("Exepected Role %s, got %s", model.RoleOwner, member.Role) + } +} diff --git a/pkg/database/testing/repos_test.go b/pkg/database/testing/repos_test.go new file mode 100644 index 000000000..a6420184b --- /dev/null +++ b/pkg/database/testing/repos_test.go @@ -0,0 +1,403 @@ +package database + +import ( + "testing" + + "github.com/drone/drone/pkg/database" +) + +// TODO unit test to verify unique constraint on Member.UserID and Member.TeamID + +// TestGetRepo tests the ability to retrieve a Repo +// from the database by Unique ID. +func TestGetRepo(t *testing.T) { + Setup() + defer Teardown() + + repo, err := database.GetRepo(1) + if err != nil { + t.Error(err) + } + + if repo.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.ID) + } + + if repo.Slug != "github.com/drone/drone" { + t.Errorf("Exepected Slug %s, got %s", "github.com/drone/drone", repo.Slug) + } + + if repo.Host != "github.com" { + t.Errorf("Exepected Host %s, got %s", "github.com", repo.Host) + } + + if repo.Owner != "drone" { + t.Errorf("Exepected Owner %s, got %s", "drone", repo.Owner) + } + + if repo.Name != "drone" { + t.Errorf("Exepected Name %s, got %s", "drone", repo.Name) + } + + if repo.Private != true { + t.Errorf("Exepected Private %v, got %v", true, repo.Private) + } + + if repo.Disabled != false { + t.Errorf("Exepected Private %v, got %v", false, repo.Disabled) + } + + if repo.SCM != "git" { + t.Errorf("Exepected Type %s, got %s", "git", repo.SCM) + } + + if repo.URL != "git@github.com:drone/drone.git" { + t.Errorf("Exepected URL %s, got %s", "git@github.com:drone/drone.git", repo.URL) + } + + if repo.Username != "no username" { + t.Errorf("Exepected Username %s, got %s", "no username", repo.Username) + } + + if repo.Password != "no password" { + t.Errorf("Exepected Password %s, got %s", "no password", repo.Password) + } + + if repo.PublicKey != "public key" { + t.Errorf("Exepected PublicKey %s, got %s", "public key", repo.PublicKey) + } + + if repo.PrivateKey != "private key" { + t.Errorf("Exepected PrivateKey %s, got %s", "private key", repo.PrivateKey) + } + + if repo.UserID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.UserID) + } + + if repo.TeamID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.TeamID) + } +} + +// TestGetRepoSlug tests the ability to retrieve a Repo +// from the database by it's Canonical Name. +func TestGetRepoSlug(t *testing.T) { + Setup() + defer Teardown() + + repo, err := database.GetRepoSlug("github.com/drone/drone") + if err != nil { + t.Error(err) + } + + if repo.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.ID) + } + + if repo.Slug != "github.com/drone/drone" { + t.Errorf("Exepected Slug %s, got %s", "github.com/drone/drone", repo.Slug) + } + + if repo.Host != "github.com" { + t.Errorf("Exepected Host %s, got %s", "github.com", repo.Host) + } + + if repo.Owner != "drone" { + t.Errorf("Exepected Owner %s, got %s", "drone", repo.Owner) + } + + if repo.Name != "drone" { + t.Errorf("Exepected Name %s, got %s", "drone", repo.Name) + } + + if repo.Private != true { + t.Errorf("Exepected Private %v, got %v", true, repo.Private) + } + + if repo.Disabled != false { + t.Errorf("Exepected Private %v, got %v", false, repo.Disabled) + } + + if repo.SCM != "git" { + t.Errorf("Exepected Type %s, got %s", "git", repo.SCM) + } + + if repo.URL != "git@github.com:drone/drone.git" { + t.Errorf("Exepected URL %s, got %s", "git@github.com:drone/drone.git", repo.URL) + } + + if repo.Username != "no username" { + t.Errorf("Exepected Username %s, got %s", "no username", repo.Username) + } + + if repo.Password != "no password" { + t.Errorf("Exepected Password %s, got %s", "no password", repo.Password) + } + + if repo.PublicKey != "public key" { + t.Errorf("Exepected PublicKey %s, got %s", "public key", repo.PublicKey) + } + + if repo.PrivateKey != "private key" { + t.Errorf("Exepected PrivateKey %s, got %s", "private key", repo.PrivateKey) + } + + if repo.UserID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.UserID) + } + + if repo.TeamID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.TeamID) + } +} + +func TestSaveRepo(t *testing.T) { + Setup() + defer Teardown() + + // get the repo we plan to update + repo, err := database.GetRepo(1) + if err != nil { + t.Error(err) + } + + // update fields + repo.Slug = "bitbucket.org/drone/drone" + repo.Host = "bitbucket.org" + repo.Private = false + repo.Disabled = true + repo.SCM = "hg" + repo.URL = "https://bitbucket.org/drone/drone" + repo.Username = "brad" + repo.Password = "password" + repo.TeamID = 0 + + // update the database + if err := database.SaveRepo(repo); err != nil { + t.Error(err) + } + + // get the updated repo + updatedRepo, err := database.GetRepo(1) + if err != nil { + t.Error(err) + } + + if updatedRepo.Slug != repo.Slug { + t.Errorf("Exepected Slug %s, got %s", updatedRepo.Slug, repo.Slug) + } + + if updatedRepo.Host != repo.Host { + t.Errorf("Exepected Host %s, got %s", updatedRepo.Host, repo.Host) + } + + if updatedRepo.Private != repo.Private { + t.Errorf("Exepected Private %v, got %v", updatedRepo.Private, repo.Private) + } + + if updatedRepo.Disabled != repo.Disabled { + t.Errorf("Exepected Private %v, got %v", updatedRepo.Disabled, repo.Disabled) + } + + if updatedRepo.SCM != repo.SCM { + t.Errorf("Exepected Type %s, got %s", true, repo.SCM) + } + + if updatedRepo.URL != repo.URL { + t.Errorf("Exepected URL %s, got %s", updatedRepo.URL, repo.URL) + } + + if updatedRepo.Username != repo.Username { + t.Errorf("Exepected Username %s, got %s", updatedRepo.Username, repo.Username) + } + + if updatedRepo.Password != repo.Password { + t.Errorf("Exepected Password %s, got %s", updatedRepo.Password, repo.Password) + } + + if updatedRepo.TeamID != repo.TeamID { + t.Errorf("Exepected TeamID %d, got %d", updatedRepo.TeamID, repo.TeamID) + } +} + +func TestDeleteRepo(t *testing.T) { + Setup() + defer Teardown() + + if err := database.DeleteRepo(1); err != nil { + t.Error(err) + } + + // try to get the deleted row + _, err := database.GetRepo(1) + if err == nil { + t.Fail() + } +} + +/* +func TestListRepos(t *testing.T) { + Setup() + defer Teardown() + + // repos for user_id = 1 + repos, err := database.ListRepos(1) + if err != nil { + t.Error(err) + } + + // verify user count + if len(repos) != 2 { + t.Errorf("Exepected %d repos in database, got %d", 2, len(repos)) + return + } + + // get the second repo in the list and verify + // fields are being populated correctly + // NOTE: we get the 2nd repo due to sorting + repo := repos[1] + + if repo.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.ID) + } + + if repo.Name != "github.com/drone/drone" { + t.Errorf("Exepected Name %s, got %s", "github.com/drone/drone", repo.Name) + } + + if repo.Host != "github.com" { + t.Errorf("Exepected Host %s, got %s", "github.com", repo.Host) + } + + if repo.Owner != "drone" { + t.Errorf("Exepected Owner %s, got %s", "drone", repo.Owner) + } + + if repo.Slug != "drone" { + t.Errorf("Exepected Slug %s, got %s", "drone", repo.Slug) + } + + if repo.Private != true { + t.Errorf("Exepected Private %v, got %v", true, repo.Private) + } + + if repo.Disabled != false { + t.Errorf("Exepected Private %v, got %v", false, repo.Disabled) + } + + if repo.SCM != "git" { + t.Errorf("Exepected Type %s, got %s", "git", repo.SCM) + } + + if repo.URL != "git@github.com:drone/drone.git" { + t.Errorf("Exepected URL %s, got %s", "git@github.com:drone/drone.git", repo.URL) + } + + if repo.Username != "no username" { + t.Errorf("Exepected Username %s, got %s", "no username", repo.Username) + } + + if repo.Password != "no password" { + t.Errorf("Exepected Password %s, got %s", "no password", repo.Password) + } + + if repo.PublicKey != "public key" { + t.Errorf("Exepected PublicKey %s, got %s", "public key", repo.PublicKey) + } + + if repo.PrivateKey != "private key" { + t.Errorf("Exepected PrivateKey %s, got %s", "private key", repo.PrivateKey) + } + + if repo.UserID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.UserID) + } + + if repo.TeamID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.TeamID) + } +} +*/ + +func TestListReposTeam(t *testing.T) { + Setup() + defer Teardown() + + // repos for team_id = 1 + repos, err := database.ListReposTeam(1) + if err != nil { + t.Error(err) + } + + // verify user count + if len(repos) != 2 { + t.Errorf("Exepected %d repos in database, got %d", 2, len(repos)) + return + } + + // get the second repo in the list and verify + // fields are being populated correctly + // NOTE: we get the 2nd repo due to sorting + repo := repos[1] + + if repo.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.ID) + } + + if repo.Slug != "github.com/drone/drone" { + t.Errorf("Exepected Slug %s, got %s", "github.com/drone/drone", repo.Slug) + } + + if repo.Host != "github.com" { + t.Errorf("Exepected Host %s, got %s", "github.com", repo.Host) + } + + if repo.Owner != "drone" { + t.Errorf("Exepected Owner %s, got %s", "drone", repo.Owner) + } + + if repo.Name != "drone" { + t.Errorf("Exepected Name %s, got %s", "drone", repo.Name) + } + + if repo.Private != true { + t.Errorf("Exepected Private %v, got %v", true, repo.Private) + } + + if repo.Disabled != false { + t.Errorf("Exepected Private %v, got %v", false, repo.Disabled) + } + + if repo.SCM != "git" { + t.Errorf("Exepected Type %s, got %s", "git", repo.SCM) + } + + if repo.URL != "git@github.com:drone/drone.git" { + t.Errorf("Exepected URL %s, got %s", "git@github.com:drone/drone.git", repo.URL) + } + + if repo.Username != "no username" { + t.Errorf("Exepected Username %s, got %s", "no username", repo.Username) + } + + if repo.Password != "no password" { + t.Errorf("Exepected Password %s, got %s", "no password", repo.Password) + } + + if repo.PublicKey != "public key" { + t.Errorf("Exepected PublicKey %s, got %s", "public key", repo.PublicKey) + } + + if repo.PrivateKey != "private key" { + t.Errorf("Exepected PrivateKey %s, got %s", "private key", repo.PrivateKey) + } + + if repo.UserID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.UserID) + } + + if repo.TeamID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, repo.TeamID) + } +} diff --git a/pkg/database/testing/settings_test.go b/pkg/database/testing/settings_test.go new file mode 100644 index 000000000..c8a9eec1e --- /dev/null +++ b/pkg/database/testing/settings_test.go @@ -0,0 +1,63 @@ +package database + +import ( + "testing" + + "github.com/drone/drone/pkg/database" +) + +func TestGetSettings(t *testing.T) { + Setup() + defer Teardown() + + // even though no settings exist yet, we should + // not see an error since we supress the msg + settings, err := database.GetSettings() + //if err != nil { + // t.Error(err) + //} + + // add some settings + //settings := &modelSettings{} + settings.Scheme = "https" + settings.Domain = "foo.com" + settings.BitbucketKey = "bitbucketkey" + settings.BitbucketSecret = "bitbucketsecret" + settings.GitHubKey = "githubkey" + settings.GitHubSecret = "githubsecret" + settings.SmtpAddress = "noreply@foo.bar" + settings.SmtpServer = "0.0.0.0" + settings.SmtpUsername = "username" + settings.SmtpPassword = "password" + + // save the updated settings + if err := database.SaveSettings(settings); err != nil { + t.Error(err) + } + + // re-retrieve the settings post-save + settings, err = database.GetSettings() + if err != nil { + t.Error(err) + } + + if settings.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, settings.ID) + } + + if settings.Scheme != "https" { + t.Errorf("Exepected Scheme %s, got %s", "https", settings.Scheme) + } + + if settings.Domain != "foo.com" { + t.Errorf("Exepected Domain %s, got %s", "foo.com", settings.Domain) + } + + // Verify caching works and is threadsafe + settingsA, _ := database.GetSettings() + settingsB, _ := database.GetSettings() + settingsA.Domain = "foo.bar.baz" + if settingsA.Domain == settingsB.Domain { + t.Errorf("Exepected Domain ThreadSafe and unchanged") + } +} diff --git a/pkg/database/testing/teams_test.go b/pkg/database/testing/teams_test.go new file mode 100644 index 000000000..b86634391 --- /dev/null +++ b/pkg/database/testing/teams_test.go @@ -0,0 +1,169 @@ +package database + +import ( + "testing" + + "github.com/drone/drone/pkg/database" +) + +// TODO unit test to verify unique constraint on Member.UserID and Member.TeamID + +// TestGetTeam tests the ability to retrieve a Team +// from the database by Unique ID. +func TestGetTeam(t *testing.T) { + Setup() + defer Teardown() + + team, err := database.GetTeam(1) + if err != nil { + t.Error(err) + } + + if team.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, team.ID) + } + + if team.Name != "Drone" { + t.Errorf("Exepected Name %s, got %s", "Drone", team.Name) + } + + if team.Slug != "drone" { + t.Errorf("Exepected Slug %s, got %s", "drone", team.Slug) + } + + if team.Email != "support@drone.io" { + t.Errorf("Exepected Email %s, got %s", "brad@drone.io", team.Email) + } + + if team.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" { + t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", team.Gravatar) + } +} + +// TestGetTeamName tests the ability to retrieve a Team +// from the database by Unique Team Name (aka Slug). +func TestGetTeamSlug(t *testing.T) { + Setup() + defer Teardown() + + team, err := database.GetTeamSlug("drone") + if err != nil { + t.Error(err) + } + + if team.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, team.ID) + } + + if team.Name != "Drone" { + t.Errorf("Exepected Name %s, got %s", "Drone", team.Name) + } + + if team.Slug != "drone" { + t.Errorf("Exepected Slug %s, got %s", "drone", team.Slug) + } + + if team.Email != "support@drone.io" { + t.Errorf("Exepected Email %s, got %s", "brad@drone.io", team.Email) + } + + if team.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" { + t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", team.Gravatar) + } +} + +// TestUpdateTeam tests the ability to updatee an +// existing Team in the database. +func TestUpdateTeam(t *testing.T) { + Setup() + defer Teardown() + + // get the user we plan to update + team, err := database.GetTeam(1) + if err != nil { + t.Error(err) + } + + // update fields + team.Email = "brad@drone.io" + team.Gravatar = "61024896f291303615bcd4f7a0dcfb74" + + // update the database + if err := database.SaveTeam(team); err != nil { + t.Error(err) + } + + // get the updated team + updatedTeam, err := database.GetTeam(1) + if err != nil { + t.Error(err) + } + + // verify the updated fields + if team.Email != updatedTeam.Email { + t.Errorf("Exepected Email %s, got %s", team.Email, updatedTeam.Email) + } + + if team.Gravatar != updatedTeam.Gravatar { + t.Errorf("Exepected Gravatar %s, got %s", team.Gravatar, updatedTeam.Gravatar) + } +} + +// Test the ability to delete a Team. +func TestDeleteTeam(t *testing.T) { + Setup() + defer Teardown() + + // get the team we plan to update + if err := database.DeleteTeam(1); err != nil { + t.Error(err) + } + + // now try to get the team from the database + _, err := database.GetTeam(1) + if err == nil { + t.Fail() + } +} + +// Test the ability to get a list of Teams +// to which a User belongs. +func TestListTeam(t *testing.T) { + Setup() + defer Teardown() + + teams, err := database.ListTeams(1) + if err != nil { + t.Error(err) + } + + // verify team count + if len(teams) != 3 { + t.Errorf("Exepected %d teams in database, got %d", 3, len(teams)) + return + } + + // get the first user in the list and verify + // fields are being populated correctly + team := teams[0] + + if team.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, team.ID) + } + + if team.Name != "Drone" { + t.Errorf("Exepected Name %s, got %s", "Drone", team.Name) + } + + if team.Slug != "drone" { + t.Errorf("Exepected Slug %s, got %s", "drone", team.Slug) + } + + if team.Email != "support@drone.io" { + t.Errorf("Exepected Email %s, got %s", "brad@drone.io", team.Email) + } + + if team.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" { + t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", team.Gravatar) + } +} diff --git a/pkg/database/testing/testing.go b/pkg/database/testing/testing.go new file mode 100644 index 000000000..11106e2d3 --- /dev/null +++ b/pkg/database/testing/testing.go @@ -0,0 +1,207 @@ +package database + +import ( + "crypto/aes" + "database/sql" + "log" + + "github.com/drone/drone/pkg/database" + "github.com/drone/drone/pkg/database/encrypt" + . "github.com/drone/drone/pkg/model" + + _ "github.com/mattn/go-sqlite3" + "github.com/russross/meddler" +) + +// in-memory database used for +// unit testing purposes. +var db *sql.DB + +func init() { + // create a cipher for ecnrypting and decrypting + // database fields + cipher, err := aes.NewCipher([]byte("38B241096B8DA08131563770F4CDDFAC")) + if err != nil { + log.Fatal(err) + } + + // register function with meddler to encrypt and + // decrypt database fields. + meddler.Register("gobencrypt", &encrypt.EncryptedField{cipher}) + + // notify meddler that we are working with sqlite + meddler.Default = meddler.SQLite +} + +func Setup() { + // create an in-memory database + db, _ = sql.Open("sqlite3", ":memory:") + + // make sure all the tables and indexes are created + database.Set(db) + + // create dummy user data + user1 := User{ + Password: "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", + Name: "Brad Rydzewski", + Email: "brad.rydzewski@gmail.com", + Gravatar: "8c58a0be77ee441bb8f8595b7f1b4e87", + Token: "123", + Admin: true} + user2 := User{ + Password: "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", + Name: "Thomas Burke", + Email: "cavepig@gmail.com", + Gravatar: "c62f7126273f7fa786274274a5dec8ce", + Token: "456", + Admin: false} + user3 := User{ + Password: "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", + Name: "Carlos Morales", + Email: "ytsejammer@gmail.com", + Gravatar: "c2180a539620d90d68eaeb848364f1c2", + Token: "789", + Admin: false} + + database.SaveUser(&user1) + database.SaveUser(&user2) + database.SaveUser(&user3) + + // create dummy team data + team1 := Team{ + Slug: "drone", + Name: "Drone", + Email: "support@drone.io", + Gravatar: "8c58a0be77ee441bb8f8595b7f1b4e87"} + team2 := Team{ + Slug: "github", + Name: "Github", + Email: "support@github.com", + Gravatar: "61024896f291303615bcd4f7a0dcfb74"} + team3 := Team{ + Slug: "golang", + Name: "Golang", + Email: "support@golang.org", + Gravatar: "991695cc770c6b8354b68cd18c280b95"} + + database.SaveTeam(&team1) + database.SaveTeam(&team2) + database.SaveTeam(&team3) + + // create team membership data + database.SaveMember(user1.ID, team1.ID, RoleOwner) + database.SaveMember(user2.ID, team1.ID, RoleAdmin) + database.SaveMember(user3.ID, team1.ID, RoleWrite) + database.SaveMember(user1.ID, team2.ID, RoleOwner) + database.SaveMember(user2.ID, team2.ID, RoleAdmin) + database.SaveMember(user3.ID, team2.ID, RoleWrite) + database.SaveMember(user1.ID, team3.ID, RoleOwner) + + // create dummy repo data + repo1 := Repo{ + Slug: "github.com/drone/drone", + Host: "github.com", + Owner: "drone", + Name: "drone", + Private: true, + Disabled: false, + SCM: "git", + URL: "git@github.com:drone/drone.git", + Username: "no username", + Password: "no password", + PublicKey: "public key", + PrivateKey: "private key", + UserID: user1.ID, + TeamID: team1.ID, + } + repo2 := Repo{ + Slug: "bitbucket.org/drone/test", + Host: "bitbucket.org", + Owner: "drone", + Name: "test", + Private: false, + Disabled: false, + SCM: "hg", + URL: "https://bitbucket.org/drone/test", + Username: "no username", + Password: "no password", + PublicKey: "public key", + PrivateKey: "private key", + UserID: user1.ID, + TeamID: team1.ID, + } + repo3 := Repo{ + Slug: "bitbucket.org/brydzewski/test", + Host: "bitbucket.org", + Owner: "brydzewski", + Name: "test", + Private: false, + Disabled: false, + SCM: "hg", + URL: "https://bitbucket.org/brydzewski/test", + Username: "no username", + Password: "no password", + PublicKey: "public key", + PrivateKey: "private key", + UserID: user2.ID, + } + + database.SaveRepo(&repo1) + database.SaveRepo(&repo2) + database.SaveRepo(&repo3) + + commit1 := Commit{ + RepoID: repo1.ID, + Status: "Success", + Hash: "4f4c4594be6d6ddbc1c0dd521334f7ecba92b608", + Branch: "master", + Author: user1.Email, + Gravatar: user1.Gravatar, + Message: "commit message", + } + commit2 := Commit{ + RepoID: repo1.ID, + Status: "Failure", + Hash: "0eb2fa13e9f4139e803b6ad37831708d4786c74a", + Branch: "master", + Author: user1.Email, + Gravatar: user1.Gravatar, + Message: "commit message", + } + commit3 := Commit{ + RepoID: repo1.ID, + Status: "Failure", + Hash: "60a7fe87ccf01d0152e53242528399e05acaf047", + Branch: "dev", + Author: user1.Email, + Gravatar: user1.Gravatar, + Message: "commit message", + } + commit4 := Commit{ + RepoID: repo2.ID, + Status: "Success", + Hash: "a4078d1e9a0842cdd214adbf0512578799a4f2ba", + Branch: "master", + Author: user1.Email, + Gravatar: user1.Gravatar, + Message: "commit message", + } + + // create dummy commit data + database.SaveCommit(&commit1) + database.SaveCommit(&commit2) + database.SaveCommit(&commit3) + database.SaveCommit(&commit4) + + // create dummy build data + database.SaveBuild(&Build{CommitID: commit1.ID, Slug: "node_0.10", Status: "Success", Duration: 60}) + database.SaveBuild(&Build{CommitID: commit1.ID, Slug: "node_0.09", Status: "Success", Duration: 70}) + database.SaveBuild(&Build{CommitID: commit2.ID, Slug: "node_0.10", Status: "Success", Duration: 10}) + database.SaveBuild(&Build{CommitID: commit2.ID, Slug: "node_0.09", Status: "Failure", Duration: 65}) + database.SaveBuild(&Build{CommitID: commit3.ID, Slug: "node_0.10", Status: "Failure", Duration: 50}) + database.SaveBuild(&Build{CommitID: commit3.ID, Slug: "node_0.09", Status: "Failure", Duration: 55}) +} + +func Teardown() { + db.Close() +} diff --git a/pkg/database/testing/users_test.go b/pkg/database/testing/users_test.go new file mode 100644 index 000000000..9742c42bb --- /dev/null +++ b/pkg/database/testing/users_test.go @@ -0,0 +1,169 @@ +package database + +import ( + "testing" + + "github.com/drone/drone/pkg/database" +) + +// TODO unit test to verify unique constraint on User.Username +// TODO unit test to verify unique constraint on User.Email + +// TestGetUser tests the ability to retrieve a User +// from the database by Unique ID. +func TestGetUser(t *testing.T) { + Setup() + defer Teardown() + + u, err := database.GetUser(1) + if err != nil { + t.Error(err) + } + + if u.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, u.ID) + } + + if u.Password != "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS" { + t.Errorf("Exepected Password %s, got %s", "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", u.Password) + } + + if u.Name != "Brad Rydzewski" { + t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", u.Name) + } + + if u.Email != "brad.rydzewski@gmail.com" { + t.Errorf("Exepected Email %s, got %s", "brad.rydzewski@gmail.com", u.Email) + } + + if u.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" { + t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", u.Gravatar) + } +} + +// TestGetUseEmail tests the ability to retrieve a User +// from the database by Email address. +func TestGetUserEmail(t *testing.T) { + Setup() + defer Teardown() + + u, err := database.GetUserEmail("brad.rydzewski@gmail.com") + if err != nil { + t.Error(err) + } + + if u.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, u.ID) + } + + if u.Password != "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS" { + t.Errorf("Exepected Password %s, got %s", "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", u.Password) + } + + if u.Name != "Brad Rydzewski" { + t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", u.Name) + } + + if u.Email != "brad.rydzewski@gmail.com" { + t.Errorf("Exepected Email %s, got %s", "brad.rydzewski@gmail.com", u.Email) + } + + if u.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" { + t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", u.Gravatar) + } +} + +// TestUpdateUser tests the ability to updatee an +// existing User in the database. +func TestUpdateUser(t *testing.T) { + Setup() + defer Teardown() + + // get the user we plan to update + user, err := database.GetUser(1) + if err != nil { + t.Error(err) + } + + // update fields + user.Email = "brad@drone.io" + user.Password = "password" + + // update the database + if err := database.SaveUser(user); err != nil { + t.Error(err) + } + + // get the updated user + updatedUser, err := database.GetUser(1) + if err != nil { + t.Error(err) + } + + // verify the updated fields + if user.Email != updatedUser.Email { + t.Errorf("Exepected Email %s, got %s", user.Email, updatedUser.Email) + } + + if user.Password != updatedUser.Password { + t.Errorf("Exepected Password %s, got %s", user.Email, updatedUser.Password) + } +} + +// Deletes an existing User account. +func TestDeleteUser(t *testing.T) { + Setup() + defer Teardown() + + // get the user we plan to update + if err := database.DeleteUser(1); err != nil { + t.Error(err) + } + + // now try to get the user from the database + _, err := database.GetUser(1) + if err == nil { + t.Fail() + } +} + +// Returns a list of all Users. +func TestListUsers(t *testing.T) { + Setup() + defer Teardown() + + users, err := database.ListUsers() + if err != nil { + t.Error(err) + } + + // verify user count + if len(users) != 3 { + t.Errorf("Exepected %d users in database, got %d", 3, len(users)) + return + } + + // get the first user in the list and verify + // fields are being populated correctly + u := users[0] + + if u.ID != 1 { + t.Errorf("Exepected ID %d, got %d", 1, u.ID) + } + + if u.Password != "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS" { + t.Errorf("Exepected Password %s, got %s", "$2a$10$b8d63QsTL38vx7lj0HEHfOdbu1PCAg6Gfca74UavkXooIBx9YxopS", u.Password) + } + + if u.Name != "Brad Rydzewski" { + t.Errorf("Exepected Name %s, got %s", "Brad Rydzewski", u.Name) + } + + if u.Email != "brad.rydzewski@gmail.com" { + t.Errorf("Exepected Email %s, got %s", "brad.rydzewski@gmail.com", u.Email) + } + + if u.Gravatar != "8c58a0be77ee441bb8f8595b7f1b4e87" { + t.Errorf("Exepected Gravatar %s, got %s", "8c58a0be77ee441bb8f8595b7f1b4e87", u.Gravatar) + } +} diff --git a/pkg/database/users.go b/pkg/database/users.go new file mode 100644 index 000000000..602f71d84 --- /dev/null +++ b/pkg/database/users.go @@ -0,0 +1,90 @@ +package database + +import ( + "time" + + . "github.com/drone/drone/pkg/model" + "github.com/russross/meddler" +) + +// Name of the User table in the database +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, +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, +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, +github_login, github_token, bitbucket_login, bitbucket_token, bitbucket_secret +FROM users +ORDER BY name ASC +` + +// Returns the User with the given ID. +func GetUser(id int64) (*User, error) { + user := User{} + err := meddler.QueryRow(db, &user, userFindIdStmt, id) + return &user, err +} + +// Returns the User with the given email address. +func GetUserEmail(email string) (*User, error) { + user := User{} + err := meddler.QueryRow(db, &user, userFindEmailStmt, email) + return &user, err +} + +// Returns the User Password Hash for the given +// email address. +func GetPassEmail(email string) ([]byte, error) { + user, err := GetUserEmail(email) + if err != nil { + return nil, err + } + + return []byte(user.Password), nil +} + +// Saves the User account. +func SaveUser(user *User) error { + if user.ID == 0 { + user.Created = time.Now().UTC() + } + user.Updated = time.Now().UTC() + return meddler.Save(db, userTable, user) +} + +// Deletes an existing User account. +func DeleteUser(id int64) error { + db.Exec("DELETE FROM members WHERE user_id = ?", id) + db.Exec("DELETE FROM users WHERE id = ?", id) + // TODO delete all projects + return nil +} + +// Returns a list of all Users. +func ListUsers() ([]*User, error) { + var users []*User + err := meddler.QueryAll(db, &users, userStmt) + return users, err +} + +// Returns a list of Users within the specified +// range (for pagination purposes). +func ListUsersRange(limit, offset int) ([]*User, error) { + var users []*User + err := meddler.QueryAll(db, &users, userStmt) + return users, err +} diff --git a/pkg/handler/admin.go b/pkg/handler/admin.go new file mode 100644 index 000000000..be3b67a75 --- /dev/null +++ b/pkg/handler/admin.go @@ -0,0 +1,256 @@ +package handler + +import ( + "fmt" + "log" + "net/http" + "strconv" + "time" + + "github.com/dchest/authcookie" + "github.com/drone/drone/pkg/database" + "github.com/drone/drone/pkg/mail" + . "github.com/drone/drone/pkg/model" +) + +// Display a list of ALL users in the system +func AdminUserList(w http.ResponseWriter, r *http.Request, u *User) error { + users, err := database.ListUsers() + if err != nil { + return err + } + + data := struct { + User *User + Users []*User + }{u, users} + + return RenderTemplate(w, "admin_users.html", &data) +} + +// Invite a user to join the system +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 { + // generate the password reset token + email := r.FormValue("email") + token := authcookie.New(email, time.Now().Add(12*time.Hour), secret) + + // get settings + hostname := database.SettingsMust().URL().String() + emailEnabled := database.SettingsMust().SmtpServer != "" + + if !emailEnabled { + // Email is not enabled, so must let the user know the signup link + link := fmt.Sprintf("%v/register?token=%v", hostname, token) + return RenderText(w, link, http.StatusOK) + } + + // send data to template + data := struct { + Host string + Email string + Token string + }{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) + } + }() + + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +// Form to edit a user +func AdminUserEdit(w http.ResponseWriter, r *http.Request, u *User) error { + idstr := r.FormValue("id") + id, err := strconv.Atoi(idstr) + if err != nil { + return err + } + + // get the user from the database + user, err := database.GetUser(int64(id)) + if err != nil { + return err + } + + data := struct { + User *User + EditUser *User + }{u, user} + + return RenderTemplate(w, "admin_users_edit.html", &data) +} + +func AdminUserUpdate(w http.ResponseWriter, r *http.Request, u *User) error { + // get the ID from the URL parameter + idstr := r.FormValue("id") + id, err := strconv.Atoi(idstr) + if err != nil { + return err + } + + // get the user from the database + user, err := database.GetUser(int64(id)) + if err != nil { + return err + } + + // update if user is administrator or not + switch r.FormValue("Admin") { + case "true": + user.Admin = true + case "false": + user.Admin = false + } + + // saving user + if err := database.SaveUser(user); err != nil { + return err + } + + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +func AdminUserDelete(w http.ResponseWriter, r *http.Request, u *User) error { + // get the ID from the URL parameter + idstr := r.FormValue("id") + id, err := strconv.Atoi(idstr) + if err != nil { + return err + } + + // cannot delete self + if u.ID == int64(id) { + return RenderForbidden(w) + } + + // delete the user + if err := database.DeleteUser(int64(id)); err != nil { + return err + } + + http.Redirect(w, r, "/account/admin/users", http.StatusSeeOther) + return nil +} + +// Display a list of ALL users in the system +func AdminSettings(w http.ResponseWriter, r *http.Request, u *User) error { + // get settings from database + settings := database.SettingsMust() + + data := struct { + User *User + Settings *Settings + }{u, settings} + + 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() + + // update smtp settings + settings.Domain = r.FormValue("Domain") + settings.Scheme = r.FormValue("Scheme") + + // update bitbucket settings + settings.BitbucketKey = r.FormValue("BitbucketKey") + settings.BitbucketSecret = r.FormValue("BitbucketSecret") + + // update github settings + settings.GitHubKey = r.FormValue("GitHubKey") + settings.GitHubSecret = r.FormValue("GitHubSecret") + + // update smtp settings + settings.SmtpServer = r.FormValue("SmtpServer") + settings.SmtpPort = r.FormValue("SmtpPort") + settings.SmtpAddress = r.FormValue("SmtpAddress") + settings.SmtpUsername = r.FormValue("SmtpUsername") + settings.SmtpPassword = r.FormValue("SmtpPassword") + + // persist changes + if err := database.SaveSettings(settings); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + + // make sure the mail package is updated with the + // latest client information. + //mail.SetClient(&mail.SMTPClient{ + // Host: settings.SmtpServer, + // Port: settings.SmtpPort, + // User: settings.SmtpUsername, + // Pass: settings.SmtpPassword, + // From: settings.SmtpAddress, + //}) + + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +func Install(w http.ResponseWriter, r *http.Request) error { + // we can only perform the inital installation if no + // users exist in the system + if users, err := database.ListUsers(); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } else if len(users) != 0 { + // if users exist in the systsem + // we should render a NotFound page + return RenderNotFound(w) + } + + return RenderTemplate(w, "install.html", true) +} + +func InstallPost(w http.ResponseWriter, r *http.Request) error { + // we can only perform the inital installation if no + // users exist in the system + if users, err := database.ListUsers(); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } else if len(users) != 0 { + // if users exist in the systsem + // we should render a NotFound page + return RenderNotFound(w) + } + + // set the email and name + user := NewUser(r.FormValue("name"), r.FormValue("email")) + user.Admin = true + + // set the new password + if err := user.SetPassword(r.FormValue("password")); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + + // verify fields are correct + if err := user.Validate(); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + + // save to the database + if err := database.SaveUser(user); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + + // update settings + settings := Settings{} + settings.Domain = r.FormValue("Domain") + settings.Scheme = r.FormValue("Scheme") + database.SaveSettings(&settings) + + // add the user to the session object + // so that he/she is loggedin + SetCookie(w, r, "_sess", user.Email) + + // send the user to the settings page + // to complete the configuration. + http.Redirect(w, r, "/account/admin/settings", http.StatusSeeOther) + return nil +} diff --git a/pkg/handler/app.go b/pkg/handler/app.go new file mode 100644 index 000000000..86cd1121d --- /dev/null +++ b/pkg/handler/app.go @@ -0,0 +1,185 @@ +package handler + +import ( + "crypto/rand" + "io" + "log" + "net/http" + "time" + + "github.com/dchest/authcookie" + "github.com/dchest/passwordreset" + "github.com/drone/drone/pkg/database" + "github.com/drone/drone/pkg/mail" + . "github.com/drone/drone/pkg/model" +) + +var ( + // Secret key used to sign auth cookies, + // password reset tokens, etc. + secret = generateRandomKey(256) +) + +// GenerateRandomKey creates a random key of size length bytes +func generateRandomKey(strength int) []byte { + k := make([]byte, strength) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + return nil + } + return k +} + +// Returns an HTML index.html page if the user is +// not currently authenticated, otherwise redirects +// the user to their personal dashboard screen +func Index(w http.ResponseWriter, r *http.Request) error { + // is the user already authenticated then + // redirect to the dashboard page + if _, err := r.Cookie("_sess"); err == nil { + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return nil + } + + // otherwise redirect to the login page + http.Redirect(w, r, "/login", http.StatusSeeOther) + return nil +} + +// Return an HTML form for the User to login. +func Login(w http.ResponseWriter, r *http.Request) error { + return RenderTemplate(w, "login.html", nil) +} + +// Terminate the User session. +func Logout(w http.ResponseWriter, r *http.Request) error { + DelCookie(w, r, "_sess") + + http.Redirect(w, r, "/login", http.StatusSeeOther) + return nil +} + +// Return an HTML form for the User to request a password reset. +func Forgot(w http.ResponseWriter, r *http.Request) error { + return RenderTemplate(w, "forgot.html", nil) +} + +// Return an HTML form for the User to perform a password reset. +// This page must be visited from a Password Reset email that +// contains a hash to verify the User's identity. +func Reset(w http.ResponseWriter, r *http.Request) error { + return RenderTemplate(w, "reset.html", &struct{ Error string }{""}) +} + +// 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. +func Register(w http.ResponseWriter, r *http.Request) error { + return RenderTemplate(w, "register.html", &struct{ Error string }{""}) +} + +func ForgotPost(w http.ResponseWriter, r *http.Request) error { + email := r.FormValue("email") + + // attempt to retrieve the user by email address + user, err := database.GetUserEmail(email) + if err != nil { + log.Printf("could not find user %s to reset password. %s", email, err) + // if we can't find the email, we still display + // the template to the user. This prevents someone + // from trying to guess passwords through trial & error + return RenderTemplate(w, "forgot_sent.html", nil) + } + + // hostname from settings + hostname := database.SettingsMust().URL().String() + + // generate the password reset token + token := passwordreset.NewToken(user.Email, 12*time.Hour, []byte(user.Password), secret) + data := struct { + Host string + User *User + Token string + }{hostname, user, token} + + // send the email message async + go func() { + if err := mail.SendPassword(email, data); err != nil { + log.Printf("error sending password reset email to %s. %s", email, err) + } + }() + + // render the template indicating a success + return RenderTemplate(w, "forgot_sent.html", nil) +} + +func ResetPost(w http.ResponseWriter, r *http.Request) error { + // verify the token and extract the username + token := r.FormValue("token") + email, err := passwordreset.VerifyToken(token, database.GetPassEmail, secret) + if err != nil { + return RenderTemplate(w, "reset.html", &struct{ Error string }{"Your password reset request is expired."}) + } + + // get the user from the database + user, err := database.GetUserEmail(email) + if err != nil { + return RenderTemplate(w, "reset.html", &struct{ Error string }{"Unable to locate user account."}) + } + + // get the new password + password := r.FormValue("password") + if err := user.SetPassword(password); err != nil { + return RenderTemplate(w, "reset.html", &struct{ Error string }{err.Error()}) + } + + // save to the database + if err := database.SaveUser(user); err != nil { + return RenderTemplate(w, "reset.html", &struct{ Error string }{"Unable to update password. Please try again"}) + } + + // 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 RegisterPost(w http.ResponseWriter, r *http.Request) error { + // verify the token and extract the username + token := r.FormValue("token") + email := authcookie.Login(token, secret) + if len(email) == 0 { + return RenderTemplate(w, "register.html", &struct{ Error string }{"Your registration email is expired."}) + } + + // set the email and name + user := User{} + user.SetEmail(email) + user.Name = r.FormValue("name") + + // set the new password + password := r.FormValue("password") + if err := user.SetPassword(password); err != nil { + return RenderTemplate(w, "register.html", &struct{ Error string }{err.Error()}) + } + + // verify fields are correct + if err := user.Validate(); err != nil { + return RenderTemplate(w, "register.html", &struct{ Error string }{err.Error()}) + } + + // save to the database + if err := database.SaveUser(&user); err != nil { + return err + } + + // add the user to the session object + SetCookie(w, r, "_sess", user.Email) + + // redirect the user to their dashboard + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return nil +} diff --git a/pkg/handler/auth.go b/pkg/handler/auth.go new file mode 100644 index 000000000..091bb494d --- /dev/null +++ b/pkg/handler/auth.go @@ -0,0 +1,91 @@ +package handler + +import ( + "net/http" + + "github.com/drone/drone/pkg/database" + . "github.com/drone/drone/pkg/model" + "github.com/drone/go-github/github" + "github.com/drone/go-github/oauth2" +) + +// Create the User session. +func Authorize(w http.ResponseWriter, r *http.Request) error { + // extract form data + username := r.FormValue("username") + password := r.FormValue("password") + returnTo := r.FormValue("return_to") + + // get the user from the database + user, err := database.GetUserEmail(username) + if err != nil { + return RenderTemplate(w, "login_error.html", nil) + } + + // verify the password + if err := user.ComparePassword(password); err != nil { + return RenderTemplate(w, "login_error.html", nil) + } + + // add the user to the session object + SetCookie(w, r, "_sess", username) + + // where should we send the user to? + if len(returnTo) == 0 { + returnTo = "/dashboard" + } + + // redirect to the homepage + http.Redirect(w, r, returnTo, http.StatusSeeOther) + return nil +} + +func LinkGithub(w http.ResponseWriter, r *http.Request, u *User) error { + + // get settings from database + settings := database.SettingsMust() + + // 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", + ClientId: settings.GitHubKey, + ClientSecret: settings.GitHubSecret, + } + + // get the OAuth code + code := r.FormValue("code") + if len(code) == 0 { + scope := "repo,repo:status,user:email" + state := "FqB4EbagQ2o" + redirect := oauth.AuthorizeRedirect(scope, state) + http.Redirect(w, r, redirect, http.StatusSeeOther) + return nil + } + + // exchange code for an auth token + token, err := oauth.GrantToken(code) + if err != nil { + return err + } + + // create the client + client := github.New(token.AccessToken) + + // get the user information + githubUser, err := client.Users.Current() + if err != nil { + return err + } + + // save the github token to the user account + u.GithubToken = token.AccessToken + u.GithubLogin = githubUser.Login + if err := database.SaveUser(u); err != nil { + return err + } + + http.Redirect(w, r, "/new/github.com", http.StatusSeeOther) + return nil +} diff --git a/pkg/handler/badges.go b/pkg/handler/badges.go new file mode 100644 index 000000000..bfe13e612 --- /dev/null +++ b/pkg/handler/badges.go @@ -0,0 +1,50 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/drone/drone/pkg/database" +) + +// Display a static badge (png format) for a specific +// 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") + hostParam := r.FormValue(":host") + ownerParam := r.FormValue(":owner") + nameParam := r.FormValue(":name") + repoSlug := fmt.Sprintf("%s/%s/%s", hostParam, ownerParam, nameParam) + + // get the repo from the database + repo, err := database.GetRepoSlug(repoSlug) + if err != nil { + http.NotFound(w, r) + return nil + } + + // get the default branch for the repository + // if no branch is provided. + if len(branchParam) == 0 { + branchParam = repo.DefaultBranch() + } + + // default badge of "unknown" + badge := "/img/build_unknown.png" + + // get the latest commit from the database + // for the requested branch + commit, err := database.GetBranch(repo.ID, branchParam) + if err == nil { + switch commit.Status { + case "Success": + badge = "/img/build_success.png" + case "Failing", "Failure": + badge = "/img/build_failing.png" + } + } + + http.Redirect(w, r, badge, http.StatusSeeOther) + return nil +} diff --git a/pkg/handler/builds.go b/pkg/handler/builds.go new file mode 100644 index 000000000..090ce50bb --- /dev/null +++ b/pkg/handler/builds.go @@ -0,0 +1,34 @@ +package handler + +import ( + "net/http" + + "github.com/drone/drone/pkg/database" + . "github.com/drone/drone/pkg/model" +) + +// Returns the combined stdout / stderr for an individual Build. +func BuildOut(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error { + hash := r.FormValue(":commit") + labl := r.FormValue(":label") + + // get the commit from the database + commit, err := database.GetCommitHash(hash, repo.ID) + if err != nil { + return err + } + + // get the build from the database + build, err := database.GetBuildSlug(labl, commit.ID) + if err != nil { + return err + } + + return RenderText(w, build.Stdout, http.StatusOK) +} + +// Returns the gzipped stdout / stderr for an individual Build +func BuildOutGzip(w http.ResponseWriter, r *http.Request, u *User) error { + // TODO + return nil +} diff --git a/pkg/handler/commits.go b/pkg/handler/commits.go new file mode 100644 index 000000000..8ad1b0a12 --- /dev/null +++ b/pkg/handler/commits.go @@ -0,0 +1,56 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/drone/drone/pkg/channel" + "github.com/drone/drone/pkg/database" + . "github.com/drone/drone/pkg/model" +) + +// Display a specific Commit. +func CommitShow(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error { + hash := r.FormValue(":commit") + labl := r.FormValue(":label") + + // get the commit from the database + commit, err := database.GetCommitHash(hash, repo.ID) + if err != nil { + return err + } + + // get the builds from the database. a commit can have + // multiple sub-builds (or matrix builds) + builds, err := database.ListBuilds(commit.ID) + if err != nil { + return err + } + + data := struct { + User *User + Repo *Repo + Commit *Commit + Build *Build + Builds []*Build + Token string + }{u, repo, commit, builds[0], builds, ""} + + // get the specific build requested by the user. instead + // of a database round trip, we can just loop through the + // list and extract the requested build. + for _, b := range builds { + if b.Slug == labl { + data.Build = b + break + } + } + + // generate a token to connect with the websocket + // handler and stream output, if the build is running. + data.Token = channel.Token(fmt.Sprintf( + "%s/commit/%s/builds/%s", repo.Slug, commit.Hash, builds[0].Slug)) + + // render the repository template. + return RenderTemplate(w, "repo_commit.html", &data) +} diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go new file mode 100644 index 000000000..2b3d28069 --- /dev/null +++ b/pkg/handler/handler.go @@ -0,0 +1,192 @@ +package handler + +import ( + "fmt" + "log" + "net/http" + "net/url" + + "github.com/drone/drone/pkg/database" + . "github.com/drone/drone/pkg/model" +) + +// ErrorHandler wraps the default http.HandleFunc to handle an +// error as the return value. +type ErrorHandler func(w http.ResponseWriter, r *http.Request) error + +func (h ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if err := h(w, r); err != nil { + log.Print(err) + } +} + +// UserHandler 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. +type UserHandler func(w http.ResponseWriter, r *http.Request, user *User) error + +func (h UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user, err := readUser(r) + if err != nil { + redirectLogin(w, r) + return + } + + if err = h(w, r, user); err != nil { + log.Print(err) + RenderError(w, err, http.StatusBadRequest) + } +} + +// 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. +type AdminHandler func(w http.ResponseWriter, r *http.Request, user *User) error + +func (h AdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user, err := readUser(r) + if err != nil { + redirectLogin(w, r) + return + } + + // User MUST have administrative priveleges in order + // to execute the handler. + if user.Admin == false { + RenderNotFound(w) + return + } + + if err = h(w, r, user); err != nil { + log.Print(err) + RenderError(w, err, http.StatusBadRequest) + } +} + +// RepoHandler wraps the default http.HandlerFunc to include +// the currently authenticated User and requested Repository +// in the method signature, in addition to handling an error +// as the return value. +type RepoHandler func(w http.ResponseWriter, r *http.Request, user *User, repo *Repo) error + +func (h RepoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user, err := readUser(r) + if err != nil { + redirectLogin(w, r) + return + } + + // repository name from the URL parameters + hostParam := r.FormValue(":host") + userParam := r.FormValue(":owner") + nameParam := r.FormValue(":name") + repoName := fmt.Sprintf("%s/%s/%s", hostParam, userParam, nameParam) + + repo, err := database.GetRepoSlug(repoName) + if err != nil { + RenderNotFound(w) + return + } + + // The User must own the repository OR be a member + // of the Team that owns the repository. + if user.ID != repo.UserID { + if member, _ := database.IsMember(user.ID, repo.TeamID); !member { + RenderNotFound(w) + return + } + } + + if err = h(w, r, user, repo); err != nil { + log.Print(err) + RenderError(w, err, http.StatusBadRequest) + } +} + +// RepoHandler wraps the default http.HandlerFunc to include +// the currently authenticated User and requested Repository +// in the method signature, in addition to handling an error +// as the return value. +type RepoAdminHandler func(w http.ResponseWriter, r *http.Request, user *User, repo *Repo) error + +func (h RepoAdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user, err := readUser(r) + if err != nil { + redirectLogin(w, r) + return + } + + // repository name from the URL parameters + hostParam := r.FormValue(":host") + userParam := r.FormValue(":owner") + nameParam := r.FormValue(":name") + repoName := fmt.Sprintf("%s/%s/%s", hostParam, userParam, nameParam) + + repo, err := database.GetRepoSlug(repoName) + if err != nil { + RenderNotFound(w) + return + } + + // The User must own the repository OR be a member + // of the Team that owns the repository. + if user.ID != repo.UserID { + if admin, _ := database.IsMemberAdmin(user.ID, repo.TeamID); admin == false { + RenderNotFound(w) + return + } + } + + if err = h(w, r, user, repo); err != nil { + log.Print(err) + RenderError(w, err, http.StatusBadRequest) + } +} + +// helper function that reads the currently authenticated +// user from the given http.Request. +func readUser(r *http.Request) (*User, error) { + username := GetCookie(r, "_sess") + if len(username) == 0 { + return nil, fmt.Errorf("No user session") + } + + // get the user from the database + user, err := database.GetUserEmail(username) + if err != nil || user == nil || user.ID == 0 { + return nil, err + } + + return user, nil +} + +// helper function that retrieves the repository based +// on the URL parameters +func readRepo(r *http.Request) (*Repo, error) { + // get the repo data from the URL parameters + hostParam := r.FormValue(":host") + userParam := r.FormValue(":owner") + nameParam := r.FormValue(":slug") + repoSlug := fmt.Sprintf("%s/%s/%s", hostParam, userParam, nameParam) + + // get the repo from the database + return database.GetRepoSlug(repoSlug) +} + +// helper function that sends the user to the login page. +func redirectLogin(w http.ResponseWriter, r *http.Request) { + v := url.Values{} + v.Add("return_to", r.URL.String()) + http.Redirect(w, r, "/login?"+v.Encode(), http.StatusSeeOther) +} + +func renderNotFound(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + RenderTemplate(w, "404.amber", nil) +} + +func renderBadRequest(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + RenderTemplate(w, "500.amber", nil) +} diff --git a/pkg/handler/hooks.go b/pkg/handler/hooks.go new file mode 100644 index 000000000..bd2c26a2c --- /dev/null +++ b/pkg/handler/hooks.go @@ -0,0 +1,302 @@ +package handler + +import ( + "database/sql" + "net/http" + "strconv" + "time" + + "github.com/drone/drone/pkg/build/script" + "github.com/drone/drone/pkg/database" + . "github.com/drone/drone/pkg/model" + "github.com/drone/drone/pkg/queue" + "github.com/drone/go-github/github" +) + +// Processes a generic POST-RECEIVE hook and +// attempts to trigger a build. +func Hook(w http.ResponseWriter, r *http.Request) error { + + // if this is a pull request route + // to a different handler + if r.Header.Get("X-Github-Event") == "pull_request" { + PullRequestHook(w, r) + return nil + } + + // get the payload of the message + // this should contain a json representation of the + // repository and commit details + payload := r.FormValue("payload") + + // parse the github Hook payload + hook, err := github.ParseHook([]byte(payload)) + if err != nil { + println("could not parse hook") + return RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + } + + // make sure this is being triggered because of a commit + // and not something like a tag deletion or whatever + if hook.IsTag() || hook.IsGithubPages() || + hook.IsHead() == false || hook.IsDeleted() { + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) + } + + // get the repo from the URL + repoId := r.FormValue("id") + + // get the repo from the database, return error if not found + repo, err := database.GetRepoSlug(repoId) + if err != nil { + return RenderText(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + } + + // Get the user that owns the repository + user, err := database.GetUser(repo.UserID) + if err != nil { + return RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + } + + // Verify that the commit doesn't already exist. + // We should never build the same commit twice. + _, err = database.GetCommitHash(hook.Head.Id, repo.ID) + if err != nil && err != sql.ErrNoRows { + println("commit already exists") + return RenderText(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway) + } + + // we really only need: + // * repo owner + // * repo name + // * repo host (github) + // * commit hash + // * commit timestamp + // * commit branch + // * commit message + // * commit author + // * pull request + + // once we have this data we could just send directly to the queue + // and let it handle everything else + + commit := &Commit{} + commit.RepoID = repo.ID + commit.Branch = hook.Branch() + commit.Hash = hook.Head.Id + commit.Status = "Pending" + commit.Created = time.Now().UTC() + + // extract the author and message from the commit + // this is kind of experimental, since I don't know + // what I'm doing here. + if hook.Head != nil && hook.Head.Author != nil { + commit.Message = hook.Head.Message + commit.Timestamp = hook.Head.Timestamp + commit.SetAuthor(hook.Head.Author.Email) + } else if hook.Commits != nil && len(hook.Commits) > 0 && hook.Commits[0].Author != nil { + commit.Message = hook.Commits[0].Message + commit.Timestamp = hook.Commits[0].Timestamp + commit.SetAuthor(hook.Commits[0].Author.Email) + } + + // get the drone.yml file from GitHub + client := github.New(user.GithubToken) + content, err := client.Contents.FindRef(repo.Owner, repo.Slug, ".drone.yml", commit.Branch) // TODO should this really be the hash?? + if err != nil { + msg := "No .drone.yml was found in this repository. You need to add one.\n" + if err := saveFailedBuild(commit, msg); err != nil { + return RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + } + + // decode the content. Note: Not sure this will ever happen...it basically means a GitHub API issue + raw, err := content.DecodeContent() + if err != nil { + msg := "Could not decode the yaml from GitHub. Check that your .drone.yml is a valid yaml file.\n" + if err := saveFailedBuild(commit, msg); err != nil { + return RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + } + + // parse the build script + buildscript, err := script.ParseBuild(raw) + 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 { + return RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + } + + // save the commit to the database + if err := database.SaveCommit(commit); err != nil { + return RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + // save the build to the database + build := &Build{} + build.Slug = "1" // TODO + build.CommitID = commit.ID + build.Created = time.Now().UTC() + build.Status = "Pending" + if err := database.SaveBuild(build); err != nil { + return RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + // notify websocket that a new build is pending + //realtime.CommitPending(repo.UserID, repo.TeamID, repo.ID, commit.ID, repo.Private) + //realtime.BuildPending(repo.UserID, repo.TeamID, repo.ID, commit.ID, build.ID, repo.Private) + + queue.Add(&queue.BuildTask{Repo: repo, Commit: commit, Build: build, Script: buildscript}) //Push(repo, commit, build, buildscript) + + // OK! + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +func PullRequestHook(w http.ResponseWriter, r *http.Request) { + + // get the payload of the message + // this should contain a json representation of the + // repository and commit details + payload := r.FormValue("payload") + + println("GOT PR HOOK") + println(payload) + + hook, err := github.ParsePullRequestHook([]byte(payload)) + if err != nil { + RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // ignore these + if hook.Action != "opened" && hook.Action != "synchronize" { + RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) + return + } + + // get the repo from the URL + repoId := r.FormValue("id") + + // get the repo from the database, return error if not found + repo, err := database.GetRepoSlug(repoId) + if err != nil { + RenderText(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + // Get the user that owns the repository + user, err := database.GetUser(repo.UserID) + if err != nil { + RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // Verify that the commit doesn't already exist. + // We should enver build the same commit twice. + _, err = database.GetCommitHash(hook.PullRequest.Head.Sha, repo.ID) + if err != nil && err != sql.ErrNoRows { + RenderText(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway) + return + } + + /////////////////////////////////////////////////////// + + commit := &Commit{} + commit.RepoID = repo.ID + commit.Branch = hook.PullRequest.Head.Ref + commit.Hash = hook.PullRequest.Head.Sha + commit.Status = "Pending" + commit.Created = time.Now().UTC() + commit.Gravatar = hook.PullRequest.User.GravatarId + commit.PullRequest = strconv.Itoa(hook.Number) + commit.Message = hook.PullRequest.Title + // label := p.PullRequest.Head.Labe + + // get the drone.yml file from GitHub + client := github.New(user.GithubToken) + content, err := client.Contents.FindRef(repo.Owner, repo.Slug, ".drone.yml", commit.Hash) // TODO should this really be the hash?? + if err != nil { + println(err.Error()) + RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // decode the content + raw, err := content.DecodeContent() + if err != nil { + RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // parse the build script + buildscript, err := script.ParseBuild(raw) + 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 + // triggered in the system + RenderText(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // save the commit to the database + if err := database.SaveCommit(commit); err != nil { + RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // save the build to the database + build := &Build{} + build.Slug = "1" // TODO + build.CommitID = commit.ID + build.Created = time.Now().UTC() + build.Status = "Pending" + if err := database.SaveBuild(build); err != nil { + RenderText(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // notify websocket that a new build is pending + // TODO we should, for consistency, just put this inside Queue.Add() + queue.Add(&queue.BuildTask{Repo: repo, Commit: commit, Build: build, Script: buildscript}) + + // OK! + RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +// Helper method for saving a failed build or commit in the case where it never starts to build. +// This can happen if the yaml is bad or doesn't exist. +func saveFailedBuild(commit *Commit, msg string) error { + + // Set the commit to failed + commit.Status = "Failure" + commit.Created = time.Now().UTC() + commit.Finished = commit.Created + commit.Duration = 0 + if err := database.SaveCommit(commit); err != nil { + return err + } + + // save the build to the database + build := &Build{} + build.Slug = "1" // TODO: This should not be hardcoded + build.CommitID = commit.ID + build.Created = time.Now().UTC() + build.Finished = build.Created + commit.Duration = 0 + build.Status = "Failure" + build.Stdout = msg + if err := database.SaveBuild(build); err != nil { + return err + } + + // TODO: Should the status be Error instead of Failure? + + // TODO: Do we need to update the branch table too? + + return nil + +} diff --git a/pkg/handler/members.go b/pkg/handler/members.go new file mode 100644 index 000000000..ff0dea55d --- /dev/null +++ b/pkg/handler/members.go @@ -0,0 +1,227 @@ +package handler + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/dchest/authcookie" + "github.com/drone/drone/pkg/database" + "github.com/drone/drone/pkg/mail" + . "github.com/drone/drone/pkg/model" +) + +// Display a list of Team Members. +func TeamMembers(w http.ResponseWriter, r *http.Request, u *User) error { + teamParam := r.FormValue(":team") + team, err := database.GetTeamSlug(teamParam) + if err != nil { + return err + } + // user must be a team member admin + if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + members, err := database.ListMembers(team.ID) + if err != nil { + return err + } + data := struct { + User *User + Team *Team + Members []*Member + }{u, team, members} + return RenderTemplate(w, "team_members.html", &data) +} + +// Return an HTML form for creating a new Team Member. +func TeamMemberAdd(w http.ResponseWriter, r *http.Request, u *User) error { + teamParam := r.FormValue(":team") + team, err := database.GetTeamSlug(teamParam) + if err != nil { + return err + } + if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + data := struct { + User *User + Team *Team + }{u, team} + return RenderTemplate(w, "members_add.html", &data) +} + +// Return an HTML form for editing a Team Member. +func TeamMemberEdit(w http.ResponseWriter, r *http.Request, u *User) error { + teamParam := r.FormValue(":team") + team, err := database.GetTeamSlug(teamParam) + if err != nil { + return err + } + if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + + // get the ID from the URL parameter + idstr := r.FormValue("id") + id, err := strconv.Atoi(idstr) + if err != nil { + return err + } + + user, err := database.GetUser(int64(id)) + if err != nil { + return err + } + member, err := database.GetMember(user.ID, team.ID) + if err != nil { + return err + } + data := struct { + User *User + Team *Team + Member *Member + }{u, team, member} + return RenderTemplate(w, "members_edit.html", &data) +} + +// Update a specific Team Member. +func TeamMemberUpdate(w http.ResponseWriter, r *http.Request, u *User) error { + roleParam := r.FormValue("Role") + teamParam := r.FormValue(":team") + + // get the team from the database + team, err := database.GetTeamSlug(teamParam) + if err != nil { + return RenderError(w, err, http.StatusNotFound) + } + // verify the user is a admin member of the team + if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + + // get the ID from the URL parameter + idstr := r.FormValue("id") + id, err := strconv.Atoi(idstr) + if err != nil { + return err + } + + // get the user from the database + user, err := database.GetUser(int64(id)) + if err != nil { + return RenderError(w, err, http.StatusNotFound) + } + + // add the user to the team + if err := database.SaveMember(user.ID, team.ID, roleParam); err != nil { + return RenderError(w, err, http.StatusInternalServerError) + } + + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +// Delete a specific Team Member. +func TeamMemberDelete(w http.ResponseWriter, r *http.Request, u *User) error { + // get the team from the database + teamParam := r.FormValue(":team") + team, err := database.GetTeamSlug(teamParam) + if err != nil { + return RenderNotFound(w) + } + + if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + + // get the ID from the URL parameter + idstr := r.FormValue("id") + id, err := strconv.Atoi(idstr) + if err != nil { + return err + } + + // get the user from the database + user, err := database.GetUser(int64(id)) + if err != nil { + return RenderNotFound(w) + } + // must be at least 1 member + members, err := database.ListMembers(team.ID) + if err != nil { + return err + } else if len(members) == 1 { + return fmt.Errorf("There must be at least 1 member per team") + } + // delete the member + database.DeleteMember(user.ID, team.ID) + http.Redirect(w, r, fmt.Sprintf("/account/team/%s/members", team.Name), http.StatusSeeOther) + return nil +} + +// Invite a new Team Member. +func TeamMemberInvite(w http.ResponseWriter, r *http.Request, u *User) error { + teamParam := r.FormValue(":team") + mailParam := r.FormValue("email") + team, err := database.GetTeamSlug(teamParam) + if err != nil { + return RenderError(w, err, http.StatusNotFound) + } + if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + + // 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) + + // hostname from settings + hostname := database.SettingsMust().URL().String() + emailEnabled := database.SettingsMust().SmtpServer != "" + + if !emailEnabled { + // Email is not enabled, so must let the user know the signup link + link := fmt.Sprintf("%v/accept?token=%v", hostname, token) + return RenderText(w, link, http.StatusOK) + } + + // send the invitation + data := struct { + User *User + Team *Team + Token string + Host string + }{u, team, token, hostname} + + // send email async + go mail.SendInvitation(team.Name, mailParam, &data) + + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +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 { + 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) + if err != nil { + return RenderError(w, err, http.StatusNotFound) + } + + // add the user to the team. + // by default the user has write access to the team, which means + // they can add and manage new repositories. + if err := database.SaveMember(u.ID, team.ID, RoleWrite); err != nil { + return RenderError(w, err, http.StatusInternalServerError) + } + + // send the user to the dashboard + http.Redirect(w, r, "/dashboard/team/"+team.Name, http.StatusSeeOther) + return nil +} diff --git a/pkg/handler/repos.go b/pkg/handler/repos.go new file mode 100644 index 000000000..59453aac4 --- /dev/null +++ b/pkg/handler/repos.go @@ -0,0 +1,281 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/drone/drone/pkg/channel" + "github.com/drone/drone/pkg/database" + . "github.com/drone/drone/pkg/model" + "github.com/drone/go-github/github" + + "launchpad.net/goyaml" +) + +// Display a Repository dashboard. +func RepoDashboard(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error { + branch := r.FormValue(":branch") + + // get a list of all branches + branches, err := database.ListBranches(repo.ID) + if err != nil { + return err + } + + // if no branch is provided then we'll + // want to use a default value. + if len(branch) == 0 { + branch = repo.DefaultBranch() + } + + // get a list of recent commits for the + // repository and specific branch + commits, err := database.ListCommits(repo.ID, branch) + if err != nil { + return err + } + + // get a token that can be exchanged with the + // websocket handler to authorize listening + // for a stream of changes for this repository + token := channel.Create(repo.Slug) + + data := struct { + User *User + Repo *Repo + Branches []*Commit + Commits []*Commit + Branch string + Token string + }{u, repo, branches, commits, branch, token} + + return RenderTemplate(w, "repo_dashboard.html", &data) +} + +func RepoAdd(w http.ResponseWriter, r *http.Request, u *User) error { + teams, err := database.ListTeams(u.ID) + if err != nil { + return err + } + data := struct { + User *User + Teams []*Team + }{u, teams} + // if the user hasn't linked their GitHub account + // render a different template + if len(u.GithubToken) == 0 { + return RenderTemplate(w, "github_link.html", &data) + } + // otherwise display the template for adding + // a new GitHub repository. + return RenderTemplate(w, "github_add.html", &data) +} + +func RepoCreateGithub(w http.ResponseWriter, r *http.Request, u *User) error { + teamName := r.FormValue("team") + owner := r.FormValue("owner") + name := r.FormValue("name") + + // get the github settings from the database + settings := database.SettingsMust() + + // create the GitHub client + client := github.New(u.GithubToken) + githubRepo, err := client.Repos.Find(owner, name) + if err != nil { + return err + } + + repo, err := NewGitHubRepo(owner, name, githubRepo.Private) + if err != nil { + return err + } + + repo.UserID = u.ID + + // if the user chose to assign to a team account + // we need to retrieve the team, verify the user + // has access, and then set the team id. + if len(teamName) > 0 { + team, err := database.GetTeamSlug(teamName) + if err != nil { + return err + } + + // user must be an admin member of the team + if ok, _ := database.IsMemberAdmin(u.ID, team.ID); !ok { + return fmt.Errorf("Forbidden") + } + + repo.TeamID = team.ID + } + + // if the repository is private we'll need + // to upload a github key to the repository + if repo.Private { + // name the key + keyName := fmt.Sprintf("%s@%s", repo.Owner, settings.Domain) + + // create the github key, or update if one already exists + _, err := client.RepoKeys.CreateUpdate(owner, name, repo.PublicKey, keyName) + if err != nil { + return fmt.Errorf("Unable to add Private Key to your GitHub repository") + } + } + + // create a hook so that we get notified when code + // is pushed to the repository and can execute a build. + link := fmt.Sprintf("%s://%s/hook/github.com?id=%s", settings.Scheme, settings.Domain, repo.Slug) + + // add the hook + if _, err := client.Hooks.CreateUpdate(owner, name, link); err != nil { + return fmt.Errorf("Unable to add Hook to your GitHub repository. %s", err.Error()) + } + + // Save to the database + if err := database.SaveRepo(repo); err != nil { + return err + } + + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +// Repository Settings +func RepoSettingsForm(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error { + + // get the list of teams + teams, err := database.ListTeams(u.ID) + if err != nil { + return err + } + + data := struct { + Repo *Repo + User *User + Teams []*Team + Owner *User + Team *Team + }{Repo: repo, User: u, Teams: teams} + + // get the repo owner + if repo.TeamID > 0 { + data.Team, err = database.GetTeam(repo.TeamID) + if err != nil { + return err + } + } + + // get the team owner + data.Owner, err = database.GetUser(repo.UserID) + if err != nil { + return err + } + + return RenderTemplate(w, "repo_settings.html", &data) +} + +// Repository Params (YAML parameters) Form +func RepoParamsForm(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error { + + data := struct { + Repo *Repo + User *User + Textarea string + }{repo, u, ""} + + if repo.Params != nil && len(repo.Params) != 0 { + raw, _ := goyaml.Marshal(&repo.Params) + data.Textarea = string(raw) + } + + return RenderTemplate(w, "repo_params.html", &data) +} + +func RepoBadges(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error { + // hostname from settings + hostname := database.SettingsMust().URL().String() + + data := struct { + Repo *Repo + User *User + Host string + }{repo, u, hostname} + return RenderTemplate(w, "repo_badges.html", &data) +} + +func RepoKeys(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error { + data := struct { + Repo *Repo + User *User + }{repo, u} + return RenderTemplate(w, "repo_keys.html", &data) +} + +// Updates an existing repository. +func RepoUpdate(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error { + switch r.FormValue("action") { + case "params": + repo.Params = map[string]string{} + if err := goyaml.Unmarshal([]byte(r.FormValue("params")), &repo.Params); err != nil { + return err + } + default: + repo.Disabled = len(r.FormValue("Disabled")) == 0 + repo.DisabledPullRequest = len(r.FormValue("DisabledPullRequest")) == 0 + + // value of "" indicates the currently authenticated user + // should be set as the administrator. + if len(r.FormValue("Owner")) == 0 { + repo.UserID = u.ID + repo.TeamID = 0 + } else { + // else the user has chosen a team + team, err := database.GetTeamSlug(r.FormValue("Owner")) + if err != nil { + return err + } + + // verify the user is a member of the team + if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + + // set the team ID + repo.TeamID = team.ID + } + } + + // save the page + if err := database.SaveRepo(repo); err != nil { + return err + } + + http.Redirect(w, r, r.URL.Path, http.StatusSeeOther) + return nil +} + +// Deletes a specific repository. +func RepoDeleteForm(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error { + data := struct { + Repo *Repo + User *User + }{repo, u} + return RenderTemplate(w, "repo_delete.html", &data) +} + +// Deletes a specific repository. +func RepoDelete(w http.ResponseWriter, r *http.Request, u *User, repo *Repo) error { + // the user must confirm their password before deleting + password := r.FormValue("password") + if err := u.ComparePassword(password); err != nil { + return err + } + + // delete the repo + if err := database.DeleteRepo(repo.ID); err != nil { + return err + } + + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + return nil +} diff --git a/pkg/handler/teams.go b/pkg/handler/teams.go new file mode 100644 index 000000000..2e532ff99 --- /dev/null +++ b/pkg/handler/teams.go @@ -0,0 +1,152 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/drone/drone/pkg/database" + . "github.com/drone/drone/pkg/model" +) + +// Display a specific Team. +func TeamShow(w http.ResponseWriter, r *http.Request, u *User) error { + teamParam := r.FormValue(":team") + team, err := database.GetTeamSlug(teamParam) + if err != nil { + return err + } + if member, _ := database.IsMember(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + // list of repositories owned by Team + repos, err := database.ListReposTeam(team.ID) + if err != nil { + return err + } + // list all user teams + teams, err := database.ListTeams(u.ID) + if err != nil { + return err + } + // list of recent commits + commits, err := database.ListCommitsTeam(team.ID) + if err != nil { + return err + } + data := struct { + User *User + Team *Team + Teams []*Team + Repos []*Repo + Commits []*RepoCommit + }{u, team, teams, repos, commits} + return RenderTemplate(w, "team_dashboard.html", &data) +} + +// Return an HTML form for editing a Team. +func TeamEdit(w http.ResponseWriter, r *http.Request, u *User) error { + teamParam := r.FormValue(":team") + team, err := database.GetTeamSlug(teamParam) + if err != nil { + return err + } + if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + data := struct { + User *User + Team *Team + }{u, team} + return RenderTemplate(w, "team_profile.html", &data) +} + +// Return an HTML form for creating a Team. +func TeamAdd(w http.ResponseWriter, r *http.Request, u *User) error { + return RenderTemplate(w, "user_teams_add.html", struct{ User *User }{u}) +} + +// Create a new Team. +func TeamCreate(w http.ResponseWriter, r *http.Request, u *User) error { + // set the name and email from the form data + team := Team{} + team.SetName(r.FormValue("name")) + team.SetEmail(r.FormValue("email")) + + if err := team.Validate(); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + if err := database.SaveTeam(&team); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + + // add default member to the team (me) + if err := database.SaveMember(u.ID, team.ID, RoleOwner); err != nil { + return RenderError(w, err, http.StatusInternalServerError) + } + + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +// Update a specific Team. +func TeamUpdate(w http.ResponseWriter, r *http.Request, u *User) error { + // get team from the database + teamName := r.FormValue(":team") + team, err := database.GetTeamSlug(teamName) + if err != nil { + return fmt.Errorf("Forbidden") + } + if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + + team.Name = r.FormValue("name") + team.SetEmail(r.FormValue("email")) + + if err := team.Validate(); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + if err := database.SaveTeam(team); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +// Delete Confirmation Page +func TeamDeleteConfirm(w http.ResponseWriter, r *http.Request, u *User) error { + teamParam := r.FormValue(":team") + team, err := database.GetTeamSlug(teamParam) + if err != nil { + return err + } + if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + data := struct { + User *User + Team *Team + }{u, team} + return RenderTemplate(w, "team_delete.html", &data) +} + +// Delete a specific Team. +func TeamDelete(w http.ResponseWriter, r *http.Request, u *User) error { + // get the team from the database + teamParam := r.FormValue(":team") + team, err := database.GetTeamSlug(teamParam) + if err != nil { + return RenderNotFound(w) + } + if member, _ := database.IsMemberAdmin(u.ID, team.ID); !member { + return fmt.Errorf("Forbidden") + } + // the user must confirm their password before deleting + password := r.FormValue("password") + if err := u.ComparePassword(password); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + + database.DeleteTeam(team.ID) + http.Redirect(w, r, "/account/user/teams", http.StatusSeeOther) + return nil +} diff --git a/pkg/handler/testing/team_test.go b/pkg/handler/testing/team_test.go new file mode 100644 index 000000000..6c0859959 --- /dev/null +++ b/pkg/handler/testing/team_test.go @@ -0,0 +1,173 @@ +package testing + +import ( + //"net/http" + //"net/http/httptest" + //"net/url" + "testing" + + //"github.com/drone/drone/database" + . "github.com/drone/drone/database/testing" + //"github.com/drone/drone/handler" + . "github.com/smartystreets/goconvey/convey" +) + +func TestTeamProfilePage(t *testing.T) { + // seed the database with values + Setup() + defer Teardown() + + // dummy request + //req := http.Request{} + //req.Form = url.Values{} + + Convey("Team Profile Page", t, func() { + Convey("View Profile Information", func() { + + SkipConvey("Email Address is correct", func() { + + }) + SkipConvey("Team Name is correct", func() { + + }) + SkipConvey("GitHub Login is correct", func() { + + }) + SkipConvey("Bitbucket Login is correct", func() { + + }) + }) + Convey("Update Email Address", func() { + SkipConvey("With a Valid Email Address", func() { + + }) + SkipConvey("With an Invalid Email Address", func() { + + }) + SkipConvey("With an Empty Email Address", func() { + + }) + }) + + Convey("Update Team Name", func() { + SkipConvey("With a Valid Name", func() { + + }) + SkipConvey("With an Invalid Name", func() { + + }) + SkipConvey("With an Empty Name", func() { + + }) + }) + + Convey("Delete the Team", func() { + SkipConvey("Providing an Invalid Password", func() { + + }) + SkipConvey("Providing a Valid Password", func() { + + }) + }) + }) +} + +func TestTeamMembersPage(t *testing.T) { + // seed the database with values + Setup() + defer Teardown() + + // dummy request + //req := http.Request{} + //req.Form = url.Values{} + + Convey("Team Members Page", t, func() { + SkipConvey("View List of Team Members", func() { + + }) + SkipConvey("Add a New Team Member", func() { + + }) + + Convey("Edit a Team Member", func() { + SkipConvey("Modify the Role", func() { + + }) + SkipConvey("Change to an Invalid Role", func() { + + }) + SkipConvey("Change from Owner to Read", func() { + + }) + }) + + Convey("Delete a Team Member", func() { + SkipConvey("Delete a Read-only Member", func() { + + }) + SkipConvey("Delete the Last Member", func() { + + }) + SkipConvey("Delete the Owner", func() { + + }) + }) + + Convey("Accept Membership", func() { + SkipConvey("Valid Invitation", func() { + + }) + SkipConvey("Expired Invitation", func() { + + }) + SkipConvey("Invalid or Forged Invitation", func() { + + }) + }) + }) +} + +func TestDashboardPage(t *testing.T) { + // seed the database with values + Setup() + defer Teardown() + + // dummy request + //req := http.Request{} + //req.Form = url.Values{} + + SkipConvey("Team Dashboard", t, func() { + + }) + + SkipConvey("User Dashboard", t, func() { + + }) + + SkipConvey("Repo Dashboard", t, func() { + + }) + + SkipConvey("Repo Settings", t, func() { + + }) + + SkipConvey("Commit Dashboard", t, func() { + + }) + + Convey("User Account", t, func() { + SkipConvey("Login", func() { + + }) + SkipConvey("Logout", func() { + + }) + SkipConvey("Register", func() { + + }) + SkipConvey("Sign Up", func() { + + }) + }) +} diff --git a/pkg/handler/testing/users_test.go b/pkg/handler/testing/users_test.go new file mode 100644 index 000000000..db64c0256 --- /dev/null +++ b/pkg/handler/testing/users_test.go @@ -0,0 +1,172 @@ +package testing + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/drone/drone/database" + . "github.com/drone/drone/database/testing" + "github.com/drone/drone/handler" + . "github.com/drone/drone/model" + . "github.com/smartystreets/goconvey/convey" +) + +func TestUserProfilePage(t *testing.T) { + // seed the database with values + Setup() + defer Teardown() + + // dummy request + req := http.Request{} + req.Form = url.Values{} + + Convey("User Profile", t, func() { + SkipConvey("View Profile Information", func() { + user, _ := database.GetUser(1) + res := httptest.NewRecorder() + handler.UserUpdate(res, &req, user) + + Convey("Email Address is correct", func() { + + }) + Convey("User Name is correct", func() { + + }) + }) + Convey("Update Email Address", func() { + Convey("With a Valid Email Address", func() { + user, _ := database.GetUser(1) + req.Form.Set("name", "John Smith") + req.Form.Set("email", "John.Smith@gmail.com") + res := httptest.NewRecorder() + handler.UserUpdate(res, &req, user) + + So(res.Code, ShouldEqual, http.StatusOK) + }) + Convey("With an Invalid Email Address", func() { + user, _ := database.GetUser(1) + req.Form.Set("name", "John Smith") + req.Form.Set("email", "John.Smith") + res := httptest.NewRecorder() + handler.UserUpdate(res, &req, user) + + So(res.Code, ShouldEqual, http.StatusBadRequest) + So(res.Body.String(), ShouldContainSubstring, ErrInvalidEmail.Error()) + }) + Convey("With an Empty Email Address", func() { + user, _ := database.GetUser(1) + req.Form.Set("name", "John Smith") + req.Form.Set("email", "") + res := httptest.NewRecorder() + handler.UserUpdate(res, &req, user) + + So(res.Code, ShouldEqual, http.StatusBadRequest) + So(res.Body.String(), ShouldContainSubstring, ErrInvalidEmail.Error()) + }) + Convey("With a Duplicate Email Address", func() { + user, _ := database.GetUser(1) + req.Form.Set("name", "John Smith") + req.Form.Set("email", "cavepig@gmail.com") + res := httptest.NewRecorder() + handler.UserUpdate(res, &req, user) + + So(res.Code, ShouldEqual, http.StatusBadRequest) + }) + }) + + Convey("Update User Name", func() { + Convey("With a Valid Name", func() { + user, _ := database.GetUser(1) + req.Form.Set("name", "John Smith") + req.Form.Set("email", "John.Smith@gmail.com") + res := httptest.NewRecorder() + handler.UserUpdate(res, &req, user) + + So(res.Code, ShouldEqual, http.StatusOK) + }) + Convey("With an Empty Name", func() { + user, _ := database.GetUser(1) + req.Form.Set("name", "") + req.Form.Set("email", "John.Smith@gmail.com") + res := httptest.NewRecorder() + handler.UserUpdate(res, &req, user) + + So(res.Code, ShouldEqual, http.StatusBadRequest) + So(res.Body.String(), ShouldContainSubstring, ErrInvalidUserName.Error()) + }) + }) + + Convey("Change Password", func() { + Convey("To a Valid Password", func() { + user, _ := database.GetUser(1) + req.Form.Set("password", "password123") + res := httptest.NewRecorder() + handler.UserPassUpdate(res, &req, user) + + So(res.Code, ShouldEqual, http.StatusOK) + So(user.ComparePassword("password123"), ShouldBeNil) + }) + Convey("To an Invalid Password, too short", func() { + user, _ := database.GetUser(1) + req.Form.Set("password", "123") + res := httptest.NewRecorder() + handler.UserPassUpdate(res, &req, user) + + So(res.Code, ShouldEqual, http.StatusBadRequest) + }) + }) + Convey("Delete the Account", func() { + Convey("Providing an Invalid Password", func() { + user, _ := database.GetUser(1) + req.Form.Set("password", "password111") + res := httptest.NewRecorder() + handler.UserDelete(res, &req, user) + + So(res.Code, ShouldEqual, http.StatusBadRequest) + }) + SkipConvey("Providing a Valid Password", func() { + // TODO Skipping because there are no teampltes + // loaded which will cause a panic + user, _ := database.GetUser(2) + req.Form.Set("password", "password") + res := httptest.NewRecorder() + handler.UserDelete(res, &req, user) + + So(res.Code, ShouldEqual, http.StatusOK) + }) + }) + }) +} + +func TestUserTeamPage(t *testing.T) { + // seed the database with values + Setup() + defer Teardown() + + // dummy request + //req := http.Request{} + //req.Form = url.Values{} + + Convey("User Team Page", t, func() { + SkipConvey("View List of Teams", func() { + + }) + SkipConvey("View Empty List of Teams", func() { + + }) + }) + + Convey("Create a Team", t, func() { + SkipConvey("With an Invalid Name", func() { + + }) + SkipConvey("With an Invalid Email", func() { + + }) + SkipConvey("With a Valid Name and Email", func() { + + }) + }) +} diff --git a/pkg/handler/users.go b/pkg/handler/users.go new file mode 100644 index 000000000..abfdfec45 --- /dev/null +++ b/pkg/handler/users.go @@ -0,0 +1,111 @@ +package handler + +import ( + "net/http" + + "github.com/drone/drone/pkg/database" + . "github.com/drone/drone/pkg/model" +) + +// Display the dashboard for a specific user +func UserShow(w http.ResponseWriter, r *http.Request, u *User) error { + // list of repositories owned by User + repos, err := database.ListRepos(u.ID) + if err != nil { + return err + } + // list of user team accounts + teams, err := database.ListTeams(u.ID) + if err != nil { + return err + } + // list of recent commits + commits, err := database.ListCommitsUser(u.ID) + if err != nil { + return err + } + + data := struct { + User *User + Repos []*Repo + Teams []*Team + Commits []*RepoCommit + }{u, repos, teams, commits} + return RenderTemplate(w, "user_dashboard.html", &data) +} + +// return an HTML form for editing a user +func UserEdit(w http.ResponseWriter, r *http.Request, u *User) error { + return RenderTemplate(w, "user_profile.html", struct{ User *User }{u}) +} + +// return an HTML form for editing a user password +func UserPass(w http.ResponseWriter, r *http.Request, u *User) error { + return RenderTemplate(w, "user_password.html", struct{ User *User }{u}) +} + +// return an HTML form for deleting a user. +func UserDeleteConfirm(w http.ResponseWriter, r *http.Request, u *User) error { + return RenderTemplate(w, "user_delete.html", struct{ User *User }{u}) +} + +// update a specific user +func UserUpdate(w http.ResponseWriter, r *http.Request, u *User) error { + // set the name and email from the form data + u.Name = r.FormValue("name") + u.SetEmail(r.FormValue("email")) + + if err := u.Validate(); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + if err := database.SaveUser(u); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +// update a specific user's password +func UserPassUpdate(w http.ResponseWriter, r *http.Request, u *User) error { + // set the name and email from the form data + pass := r.FormValue("password") + if err := u.SetPassword(pass); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + // save the updated password to the database + if err := database.SaveUser(u); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + return RenderText(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +// delete a specific user. +func UserDelete(w http.ResponseWriter, r *http.Request, u *User) error { + // the user must confirm their password before deleting + password := r.FormValue("password") + if err := u.ComparePassword(password); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + // TODO we need to delete all repos, builds, commits, branches, etc + // TODO we should transfer ownership of all team-owned projects to the team owner + // delete the account + if err := database.DeleteUser(u.ID); err != nil { + return RenderError(w, err, http.StatusBadRequest) + } + + Logout(w, r) + return nil +} + +// Display a list of all Teams for the currently authenticated User. +func UserTeams(w http.ResponseWriter, r *http.Request, u *User) error { + teams, err := database.ListTeams(u.ID) + if err != nil { + return err + } + data := struct { + User *User + Teams []*Team + }{u, teams} + return RenderTemplate(w, "user_teams.html", &data) +} diff --git a/pkg/handler/util.go b/pkg/handler/util.go new file mode 100644 index 000000000..a2f978398 --- /dev/null +++ b/pkg/handler/util.go @@ -0,0 +1,112 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/dchest/authcookie" + "github.com/drone/drone/pkg/template" +) + +// ----------------------------------------------------------------------------- +// Rendering Functions + +// Renders the named template for the specified data type +// and write the output to the http.ResponseWriter. +func RenderTemplate(w http.ResponseWriter, name string, data interface{}) error { + w.Header().Add("Content-Type", "text/html; charset=utf-8") + return template.ExecuteTemplate(w, name, data) +} + +// Renders the 404 template for the specified data type +// and write the output to the http.ResponseWriter. +func RenderNotFound(w http.ResponseWriter) error { + w.Header().Add("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + return template.ExecuteTemplate(w, "404.amber", nil) +} + +// Renders the 403 template for the specified data type +// and write the output to the http.ResponseWriter. +func RenderForbidden(w http.ResponseWriter) error { + w.Header().Add("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + return template.ExecuteTemplate(w, "403.amber", nil) +} + +// RenderJson renders a JSON representation of resource v and +// writes to the http.ResposneWriter. +func RenderJson(w http.ResponseWriter, data interface{}) error { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + return json.NewEncoder(w).Encode(data) +} + +// RenderText write the plain text string to the http.ResposneWriter. +func RenderText(w http.ResponseWriter, text string, code int) error { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(code) + w.Write([]byte(text)) + return nil +} + +// RenderError renders a text representation of the Error message. +func RenderError(w http.ResponseWriter, err error, code int) error { + return RenderText(w, err.Error(), code) +} + +// ----------------------------------------------------------------------------- +// Cookie Helper functions + +// SetCookie signs and writes the cookie value. +func SetCookie(w http.ResponseWriter, r *http.Request, name, value string) { + sec := IsHttps(r) + str := authcookie.New(value, time.Now().Add(time.Hour*24), secret) + cookie := http.Cookie{ + Name: name, + Value: str, + Path: "/", + Domain: r.URL.Host, + HttpOnly: true, + Secure: sec, + } + + http.SetCookie(w, &cookie) +} + +func IsHttps(r *http.Request) bool { + if r.URL.Scheme == "https" { + return true + } + if strings.HasPrefix(r.Proto, "HTTPS") { + return true + } + if r.Header.Get("X-Forwarded-Proto") == "https" { + return true + } + return false +} + +// GetCookie retrieves and verifies the signed cookie value. +func GetCookie(r *http.Request, name string) string { + cookie, err := r.Cookie(name) + if err != nil { + return "" + } + return authcookie.Login(cookie.Value, secret) +} + +// DelCookie deletes a secure cookie. +func DelCookie(w http.ResponseWriter, r *http.Request, name string) { + cookie := http.Cookie{ + Name: name, + Value: "deleted", + Path: "/", + Domain: r.URL.Host, + MaxAge: -1, + } + + http.SetCookie(w, &cookie) +} diff --git a/pkg/mail/mail.go b/pkg/mail/mail.go new file mode 100644 index 000000000..aa63d444f --- /dev/null +++ b/pkg/mail/mail.go @@ -0,0 +1,144 @@ +package mail + +import ( + "bytes" + "fmt" + "log" + "net/smtp" + + "github.com/drone/drone/pkg/database" + "github.com/drone/drone/pkg/template" +) + +// A Message represents an email message. Addresses may be of any +// form permitted by RFC 822. +type Message struct { + Sender string + ReplyTo string // may be empty + + To string + Subject string + Body string +} + +// Sends a activation email to the User. +func SendActivation(to string, data interface{}) error { + msg := Message{} + msg.Subject = "[drone.io] Account Activation" + msg.To = to + + var buf bytes.Buffer + err := template.ExecuteTemplate(&buf, "activation.html", &data) + if err != nil { + log.Print(err) + return err + } + msg.Body = buf.String() + + return Send(&msg) +} + +// Sends an invitation to join a Team +func SendInvitation(team, to string, data interface{}) error { + msg := Message{} + msg.Subject = "Invited to join " + team + msg.To = to + + var buf bytes.Buffer + err := template.ExecuteTemplate(&buf, "invitation.html", &data) + if err != nil { + log.Print(err) + return err + } + msg.Body = buf.String() + + return Send(&msg) +} + +// Sends an email to the User's email address +// with Password reset information. +func SendPassword(to string, data interface{}) error { + msg := Message{} + msg.Subject = "[drone.io] Password Reset" + msg.To = to + + var buf bytes.Buffer + err := template.ExecuteTemplate(&buf, "reset_password.html", &data) + if err != nil { + log.Print(err) + return err + } + msg.Body = buf.String() + + return Send(&msg) +} + +// Sends a build success email to the user. +func SendSuccess(repo, to string, data interface{}) error { + msg := Message{} + msg.Subject = "[SUCCESS] " + repo + msg.To = to + + var buf bytes.Buffer + err := template.ExecuteTemplate(&buf, "success.html", &data) + if err != nil { + log.Print(err) + return err + } + msg.Body = buf.String() + + return Send(&msg) +} + +// Sends a build failure email to the user. +func SendFailure(repo, to string, data interface{}) error { + msg := Message{} + msg.Subject = "[FAILURE] " + repo + msg.To = to + + var buf bytes.Buffer + err := template.ExecuteTemplate(&buf, "failure.html", &data) + if err != nil { + log.Print(err) + return err + } + msg.Body = buf.String() + + return Send(&msg) +} + +// Send sends an email message. +func Send(msg *Message) error { + // retieve the system settings from the database + // so that we can get the SMTP details. + s, err := database.GetSettings() + if err != nil { + log.Print(err) + return err + } + + // set the FROM address + msg.Sender = s.SmtpAddress + + // 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) + addr := fmt.Sprintf("%s:%s", s.SmtpServer, s.SmtpPort) + + err = smtp.SendMail(addr, auth, msg.Sender, []string{msg.To}, []byte(body)) + if err != nil { + log.Print(err) + return err + } + + return nil +} + +// 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/model/build.go b/pkg/model/build.go new file mode 100644 index 000000000..44864156e --- /dev/null +++ b/pkg/model/build.go @@ -0,0 +1,72 @@ +package model + +import ( + "fmt" + "time" +) + +const ( + StatusNone = "None" + StatusEnqueue = "Pending" + StatusStarted = "Started" + StatusSuccess = "Success" + StatusFailure = "Failure" + StatusError = "Error" +) + +type Build struct { + ID int64 `meddler:"id,pk" json:"id"` + CommitID int64 `meddler:"commit_id" json:"-"` + Slug string `meddler:"slug" json:"slug"` + Status string `meddler:"status" json:"status"` + Started time.Time `meddler:"started,utctime" json:"started"` + Finished time.Time `meddler:"finished,utctime" json:"finished"` + Duration int64 `meddler:"duration" json:"duration"` + Created time.Time `meddler:"created,utctime" json:"created"` + Updated time.Time `meddler:"updated,utctime" json:"updated"` + Stdout string `meddler:"stdout" json:"-"` +} + +// HumanDuration returns a human-readable approximation of a duration +// (eg. "About a minute", "4 hours ago", etc.) +func (b *Build) HumanDuration() string { + d := time.Duration(b.Duration) + if seconds := int(d.Seconds()); seconds < 1 { + return "Less than a second" + } else if seconds < 60 { + return fmt.Sprintf("%d seconds", seconds) + } else if minutes := int(d.Minutes()); minutes == 1 { + return "About a minute" + } else if minutes < 60 { + return fmt.Sprintf("%d minutes", minutes) + } else if hours := int(d.Hours()); hours == 1 { + return "About an hour" + } else if hours < 48 { + return fmt.Sprintf("%d hours", hours) + } else if hours < 24*7*2 { + return fmt.Sprintf("%d days", hours/24) + } else if hours < 24*30*3 { + return fmt.Sprintf("%d weeks", hours/24/7) + } else if hours < 24*365*2 { + return fmt.Sprintf("%d months", hours/24/30) + } + return fmt.Sprintf("%f years", d.Hours()/24/365) +} + +// Returns the Started Date as an ISO8601 +// formatted string. +func (b *Build) StartedString() string { + return b.Started.Format("2006-01-02T15:04:05Z") +} + +// Returns the Started Date as an ISO8601 +// formatted string. +func (b *Build) FinishedString() string { + return b.Finished.Format("2006-01-02T15:04:05Z") +} + +// Returns true if the Build statis is Started +// or Pending, indicating it is currently running. +func (b *Build) IsRunning() bool { + return (b.Status == StatusStarted || b.Status == StatusEnqueue) +} diff --git a/pkg/model/commit.go b/pkg/model/commit.go new file mode 100644 index 000000000..6170fa51e --- /dev/null +++ b/pkg/model/commit.go @@ -0,0 +1,120 @@ +package model + +import ( + "fmt" + "time" +) + +type Commit struct { + ID int64 `meddler:"id,pk" json:"id"` + RepoID int64 `meddler:"repo_id" json:"-"` + Status string `meddler:"status" json:"status"` + Started time.Time `meddler:"started,utctime" json:"started"` + Finished time.Time `meddler:"finished,utctime" json:"finished"` + Duration int64 `meddler:"duration" json:"duration"` + Hash string `meddler:"hash" json:"hash"` + Branch string `meddler:"branch" json:"branch"` + PullRequest string `meddler:"pull_request" json:"pull_request"` + Author string `meddler:"author" json:"author"` + Gravatar string `meddler:"gravatar" json:"gravatar"` + Timestamp string `meddler:"timestamp" json:"timestamp"` + Message string `meddler:"message" json:"message"` + + Created time.Time `meddler:"created,utctime" json:"created"` + Updated time.Time `meddler:"updated,utctime" json:"updated"` +} + +// Returns the Short (--short) Commit Hash. +func (c *Commit) HashShort() string { + if len(c.Hash) > 6 { + return c.Hash[:6] + } else { + return c.Hash + } +} + +// Returns the Gravatar Image URL. +func (c *Commit) Image() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 42) } +func (c *Commit) ImageSmall() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 32) } +func (c *Commit) ImageLarge() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 160) } + +// Returns the Started Date as an ISO8601 +// formatted string. +func (c *Commit) StartedString() string { + return c.Started.Format("2006-01-02T15:04:05Z") +} + +// Returns the Created Date as an ISO8601 +// formatted string. +func (c *Commit) CreatedString() string { + return c.Created.Format("2006-01-02T15:04:05Z") +} + +// Returns the Started Date as an ISO8601 +// formatted string. +func (c *Commit) FinishedString() string { + return c.Finished.Format("2006-01-02T15:04:05Z") +} + +// Set the Author's email address and calculate the +// Gravatar hash. +func (c *Commit) SetAuthor(email string) { + c.Author = email + c.Gravatar = createGravatar(email) +} + +// Combined Repository and Commit details +type RepoCommit struct { + // Repo Details + Slug string `meddler:"slug" json:"slug"` + Host string `meddler:"host" json:"host"` + Owner string `meddler:"owner" json:"owner"` + Name string `meddler:"name" json:"name"` + + // Commit Details + Status string `meddler:"status" json:"status"` + Started time.Time `meddler:"started,utctime" json:"started"` + Finished time.Time `meddler:"finished,utctime" json:"finished"` + Duration int64 `meddler:"duration" json:"duration"` + Hash string `meddler:"hash" json:"hash"` + Branch string `meddler:"branch" json:"branch"` + PullRequest string `meddler:"pull_request" json:"pull_request"` + Author string `meddler:"author" json:"author"` + Gravatar string `meddler:"gravatar" json:"gravatar"` + Timestamp string `meddler:"timestamp" json:"timestamp"` + Message string `meddler:"message" json:"message"` + Created time.Time `meddler:"created,utctime" json:"created"` + Updated time.Time `meddler:"updated,utctime" json:"updated"` +} + +// Returns the Short (--short) Commit Hash. +func (c *RepoCommit) HashShort() string { + if len(c.Hash) > 6 { + return c.Hash[:6] + } else { + return c.Hash + } +} + +// Returns the Gravatar Image URL. +func (c *RepoCommit) Image() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 42) } +func (c *RepoCommit) ImageSmall() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 32) } +func (c *RepoCommit) ImageLarge() string { return fmt.Sprintf(GravatarPattern, c.Gravatar, 160) } + +// Returns the Started Date as an ISO8601 +// formatted string. +func (c *RepoCommit) StartedString() string { + return c.Started.Format("2006-01-02T15:04:05Z") +} + +// Returns the Created Date as an ISO8601 +// formatted string. +func (c *RepoCommit) CreatedString() string { + return c.Created.Format("2006-01-02T15:04:05Z") +} + +// Returns the Started Date as an ISO8601 +// formatted string. +func (c *RepoCommit) FinishedString() string { + return c.Finished.Format("2006-01-02T15:04:05Z") +} diff --git a/pkg/model/member.go b/pkg/model/member.go new file mode 100644 index 000000000..28b52bc58 --- /dev/null +++ b/pkg/model/member.go @@ -0,0 +1,43 @@ +package model + +import ( + "fmt" +) + +const ( + // Owners can add / remove team members, create / delete projects, + // and have rwx access to all projects owned by the team. + RoleOwner = "Owner" + + // Admins can create / delete projects and have rwx acess + // to all projects owned by the team. + RoleAdmin = "Admin" + + // Write members have rwx access to all projects + // owned by the team. They may not create new projects. + RoleWrite = "Write" + + // Read members have readonly access to all projects + // owned by the team. + RoleRead = "Read" +) + +type Role struct { + ID int64 `meddler:"id,pk"` + TeamID int64 `meddler:"team_id"` + UserID int64 `meddler:"user_id"` + Role string `meddler:"role"` +} + +type Member struct { + ID int64 `meddler:"user_id"` + Name string `meddler:"name"` + Email string `meddler:"email"` + Gravatar string `meddler:"gravatar"` + Role string `meddler:"role"` +} + +// Returns the Gravatar Image URL. +func (m *Member) Image() string { return fmt.Sprintf(GravatarPattern, m.Gravatar, 42) } +func (m *Member) ImageSmall() string { return fmt.Sprintf(GravatarPattern, m.Gravatar, 32) } +func (m *Member) ImageLarge() string { return fmt.Sprintf(GravatarPattern, m.Gravatar, 160) } diff --git a/pkg/model/repo.go b/pkg/model/repo.go new file mode 100644 index 000000000..5b89ad71c --- /dev/null +++ b/pkg/model/repo.go @@ -0,0 +1,159 @@ +package model + +import ( + "fmt" + "time" +) + +const ( + ScmGit = "git" + ScmHg = "hg" + ScmSvn = "svn" +) + +const ( + HostGithub = "github.com" + HostBitbucket = "bitbucket.org" + HostGoogle = "code.google.com" + HostCustom = "custom" +) + +const ( + DefaultBranchGit = "master" + DefaultBranchHg = "default" + DefaultBranchSvn = "trunk" +) + +const ( + githubRepoPattern = "git://github.com/%s/%s.git" + githubRepoPatternPrivate = "git@github.com:%s/%s.git" + bitbucketRepoPattern = "https://bitbucket.org/%s/%s.git" + bitbucketRepoPatternPrivate = "git@bitbucket.org:%s/%s.git" +) + +type Repo struct { + ID int64 `meddler:"id,pk" json:"id"` + + // the full, canonical name of the repository, for example: + // github.com/bradrydzewski/go.stripe + Slug string `meddler:"slug" json:"slug"` + + // the hosting service where the repository is stored, + // such as github.com, bitbucket.org, etc + Host string `meddler:"host" json:"host"` + + // the owner of the repository on the host system. + // for example, the Github username. + Owner string `meddler:"owner" json:"owner"` + + // URL-friendly version of a repository name on the + // host system. + Name string `meddler:"name" json:"name"` + + // A value of True indicates the repository is closed source, + // while a value of False indicates the project is open source. + Private bool `meddler:"private" json:"private"` + + // A value of True indicates the repository is disabled and + // no builds should be executed + Disabled bool `meddler:"disabled" json:"disabled"` + + // A value of True indicates that pull requests are disabled + // for the repository and no builds will be executed + DisabledPullRequest bool `meddler:"disabled_pr" json:"disabled_pr"` + + // indicates the type of repository, such as + // Git, Mercurial, Subversion or Bazaar. + SCM string `meddler:"scm" json:"scm"` + + // the repository URL, for example: + // git://github.com/bradrydzewski/go.stripe.git + URL string `meddler:"url" json:"url"` + + // username and password requires to authenticate + // to the repository + Username string `meddler:"username" json:"username"` + Password string `meddler:"password" json:"password"` + + // RSA key pair that will injected into the virtual machine + // .ssh/id_rsa and .ssh/id_rsa.pub files. + PublicKey string `meddler:"public_key" json:"public_key"` + PrivateKey string `meddler:"private_key" json:"public_key"` + + // Parameters stored external to the repository in YAML + // format, injected into the Build YAML at runtime. + Params map[string]string `meddler:"params,gob" json:"-"` + + // the amount of time, in seconds the build will execute + // before exceeding its timelimit and being killed. + Timeout int64 `meddler:"timeout" json:"timeout"` + + // Indicates the build should be executed in priveleged + // mode. This could, for example, be used to run Docker in Docker. + Priveleged bool `meddler:"priveleged" json:"priveleged"` + + // Foreign keys signify the User that created + // the repository and team account linked to + // the repository. + UserID int64 `meddler:"user_id" json:"user_id"` + TeamID int64 `meddler:"team_id" json:"team_id"` + + Created time.Time `meddler:"created,utctime" json:"created"` + Updated time.Time `meddler:"updated,utctime" json:"updated"` +} + +// Creates a new repository +func NewRepo(host, owner, name, scm, url string) (*Repo, error) { + repo := Repo{} + repo.URL = url + repo.SCM = scm + repo.Host = host + repo.Owner = owner + repo.Name = name + repo.Slug = fmt.Sprintf("%s/%s/%s", host, owner, name) + key, err := generatePrivateKey() + if err != nil { + return nil, err + } + + repo.PublicKey = marshalPublicKey(&key.PublicKey) + repo.PrivateKey = marshalPrivateKey(key) + return &repo, nil +} + +// Creates a new GitHub repository +func NewGitHubRepo(owner, name string, private bool) (*Repo, error) { + var url string + switch private { + case false: + url = fmt.Sprintf(githubRepoPattern, owner, name) + case true: + url = fmt.Sprintf(githubRepoPatternPrivate, owner, name) + } + return NewRepo(HostGithub, owner, name, ScmGit, url) +} + +// Creates a new Bitbucket repository +func NewBitbucketRepo(owner, name string, private bool) (*Repo, error) { + var url string + switch private { + case false: + url = fmt.Sprintf(bitbucketRepoPattern, owner, name) + case true: + url = fmt.Sprintf(bitbucketRepoPatternPrivate, owner, name) + } + return NewRepo(HostGithub, owner, name, ScmGit, url) +} + +func (r *Repo) DefaultBranch() string { + switch r.SCM { + case ScmGit: + return DefaultBranchGit + case ScmHg: + return DefaultBranchHg + case ScmSvn: + return DefaultBranchSvn + default: + return DefaultBranchGit + } +} diff --git a/pkg/model/settings.go b/pkg/model/settings.go new file mode 100644 index 000000000..ec7f69158 --- /dev/null +++ b/pkg/model/settings.go @@ -0,0 +1,36 @@ +package model + +import ( + "net/url" +) + +type Settings struct { + ID int64 `meddler:"id,pk"` + + // SMTP settings. + SmtpServer string `meddler:"smtp_server"` + SmtpPort string `meddler:"smtp_port"` + SmtpAddress string `meddler:"smtp_address"` + SmtpUsername string `meddler:"smtp_username"` + SmtpPassword string `meddler:"smtp_password"` + + // GitHub Consumer key and secret. + GitHubKey string `meddler:"github_key"` + GitHubSecret string `meddler:"github_secret"` + + // Bitbucket Consumer Key and secret. + BitbucketKey string `meddler:"bitbucket_key"` + BitbucketSecret string `meddler:"bitbucket_secret"` + + // Domain of the server, eg drone.io + Domain string `meddler:"hostname"` + + // Scheme of the server, eg https + Scheme string `meddler:"scheme"` +} + +func (s *Settings) URL() *url.URL { + return &url.URL{ + Scheme: s.Scheme, + Host: s.Domain} +} diff --git a/pkg/model/team.go b/pkg/model/team.go new file mode 100644 index 000000000..e35d2c118 --- /dev/null +++ b/pkg/model/team.go @@ -0,0 +1,67 @@ +package model + +import ( + "errors" + "fmt" + "time" +) + +// ErrInvalidTeamName is returned by the Team validation function +// when a team name is invalid. +var ErrInvalidTeamName = errors.New("Invalid Team Name") + +type Team struct { + ID int64 `meddler:"id,pk" json:"id"` + Slug string `meddler:"slug" json:"slug"` + Name string `meddler:"name" json:"name"` + Email string `meddler:"email" json:"email"` + Gravatar string `meddler:"gravatar" json:"gravatar"` + Created time.Time `meddler:"created,utctime" json:"created"` + Updated time.Time `meddler:"updated,utctime" json:"updated"` +} + +// Creates a new team with the specified email address, +// and team name. +func NewTeam(name, email string) *Team { + team := Team{} + team.SetEmail(email) + team.SetName(name) + return &team +} + +// Returns the Gravatar Image URL. +func (t *Team) Image() string { return fmt.Sprintf(GravatarPattern, t.Gravatar, 42) } +func (t *Team) ImageSmall() string { return fmt.Sprintf(GravatarPattern, t.Gravatar, 32) } +func (t *Team) ImageLarge() string { return fmt.Sprintf(GravatarPattern, t.Gravatar, 160) } + +// Set the name and calculate the slug value. +func (t *Team) SetName(name string) { + t.Name = name + t.Slug = createSlug(name) +} + +// Set the email address and calculate the +// Gravatar hash. +func (t *Team) SetEmail(email string) { + t.Email = email + t.Gravatar = createGravatar(email) +} + +// ValidatePassword will compares the supplied password to +// the user password stored in the database. +func (t *Team) Validate() error { + switch { + case len(t.Slug) == 0: + return ErrInvalidTeamName + case len(t.Slug) >= 255: + return ErrInvalidTeamName + case len(t.Email) == 0: + return ErrInvalidEmail + case len(t.Email) >= 255: + return ErrInvalidEmail + case RegexpEmail.MatchString(t.Email) == false: + return ErrInvalidEmail + default: + return nil + } +} diff --git a/pkg/model/user.go b/pkg/model/user.go new file mode 100644 index 000000000..e58df1941 --- /dev/null +++ b/pkg/model/user.go @@ -0,0 +1,109 @@ +package model + +import ( + "errors" + "fmt" + "regexp" + "time" + + "code.google.com/p/go.crypto/bcrypt" +) + +var ( + ErrInvalidUserName = errors.New("Invalid User Name") + ErrInvalidPassword = errors.New("Invalid Password") + ErrInvalidEmail = errors.New("Invalid Email Address") +) + +// Gravatar URL pattern +var GravatarPattern = "https://gravatar.com/avatar/%s?s=%v" + +// Simple regular expression used to verify that an email +// address matches the expected standard format. +var RegexpEmail = regexp.MustCompile(`^[^@]+@[^@.]+\.[^@.]+`) + +type User struct { + ID int64 `meddler:"id,pk" json:"id"` + Email string `meddler:"email" json:"email"` + Password string `meddler:"password" json:"-"` + Token string `meddler:"token" json:"-"` + Name string `meddler:"name" json:"name"` + Gravatar string `meddler:"gravatar" json:"gravatar"` + Created time.Time `meddler:"created,utctime" json:"created"` + Updated time.Time `meddler:"updated,utctime" json:"updated"` + Admin bool `meddler:"admin" json:"-"` + + // GitHub OAuth2 token for accessing public repositories. + GithubLogin string `meddler:"github_login" json:"-"` + GithubToken string `meddler:"github_token" json:"-"` + + // Bitbucket OAuth1.0a token and token secret. + BitbucketLogin string `meddler:"bitbucket_login" json:"-"` + BitbucketToken string `meddler:"bitbucket_token" json:"-"` + BitbucketSecret string `meddler:"bitbucket_secret" json:"-"` +} + +// Creates a new User from the given Name and Email. +func NewUser(name, email string) *User { + user := User{} + user.Name = name + user.Token = createToken() + user.SetEmail(email) + return &user +} + +// Returns the Gravatar Image URL. +func (u *User) Image() string { return fmt.Sprintf(GravatarPattern, u.Gravatar, 42) } +func (u *User) ImageSmall() string { return fmt.Sprintf(GravatarPattern, u.Gravatar, 32) } +func (u *User) ImageLarge() string { return fmt.Sprintf(GravatarPattern, u.Gravatar, 160) } + +// Set the email address and calculate the +// Gravatar hash. +func (u *User) SetEmail(email string) { + u.Email = email + u.Gravatar = createGravatar(email) +} + +// Set the password and hash with bcrypt +func (u *User) SetPassword(password string) error { + // validate the password is an appropriate size + switch { + case len(password) < 6: + return ErrInvalidPassword + case len(password) > 256: + return ErrInvalidPassword + } + + // convert the password to a hash + b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + // update the user + u.Password = string(b) + return nil +} + +// Validate verifies all required fields are correctly populated. +func (u *User) Validate() error { + switch { + case len(u.Name) == 0: + return ErrInvalidUserName + case len(u.Name) >= 255: + return ErrInvalidUserName + case len(u.Email) == 0: + return ErrInvalidEmail + case len(u.Email) >= 255: + return ErrInvalidEmail + case RegexpEmail.MatchString(u.Email) == false: + return ErrInvalidEmail + default: + return nil + } +} + +// ComparePassword compares the supplied password to +// the user password stored in the database. +func (u *User) ComparePassword(password string) error { + return bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) +} diff --git a/pkg/model/util.go b/pkg/model/util.go new file mode 100644 index 000000000..41000e528 --- /dev/null +++ b/pkg/model/util.go @@ -0,0 +1,92 @@ +package model + +import ( + "crypto/md5" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "strings" + "unicode" + + "code.google.com/p/go.crypto/ssh" + "code.google.com/p/go.text/unicode/norm" + "github.com/dchest/uniuri" +) + +var ( + lat = []*unicode.RangeTable{unicode.Letter, unicode.Number} + nop = []*unicode.RangeTable{unicode.Mark, unicode.Sk, unicode.Lm} +) + +// helper function to create a Gravatar Hash +// for the given Email address. +func createGravatar(email string) string { + email = strings.ToLower(strings.TrimSpace(email)) + hash := md5.New() + hash.Write([]byte(email)) + return fmt.Sprintf("%x", hash.Sum(nil)) +} + +// helper function to create a Slug for the +// given string of text. +func createSlug(s string) string { + buf := make([]rune, 0, len(s)) + dash := false + for _, r := range norm.NFKD.String(s) { + switch { + // unicode 'letters' like mandarin characters pass through + case unicode.IsOneOf(lat, r): + buf = append(buf, unicode.ToLower(r)) + dash = true + case unicode.IsOneOf(nop, r): + // skip + case dash: + buf = append(buf, '-') + dash = false + } + } + if i := len(buf) - 1; i >= 0 && buf[i] == '-' { + buf = buf[:i] + } + return string(buf) +} + +// helper function to create a random 40-byte +// Token that is URL-friendly. +func createToken() string { + return uniuri.NewLen(40) +} + +// ----------------------------------------------------------------------------- +// SSH Functions + +const ( + RSA_BITS = 2048 // Default number of bits in an RSA key + RSA_BITS_MIN = 768 // Minimum number of bits in an RSA key +) + +// helper function to generate an RSA Private Key. +func generatePrivateKey() (*rsa.PrivateKey, error) { + return rsa.GenerateKey(rand.Reader, RSA_BITS) +} + +// helper function that marshalls an RSA Public Key to an SSH +// .authorized_keys format +func marshalPublicKey(pubkey *rsa.PublicKey) string { + pk, err := ssh.NewPublicKey(pubkey) + if err != nil { + return "" + } + + return string(ssh.MarshalAuthorizedKey(pk)) +} + +// helper function that marshalls an RSA Private Key to +// a PEM encoded file. +func marshalPrivateKey(privkey *rsa.PrivateKey) string { + privateKeyMarshaled := x509.MarshalPKCS1PrivateKey(privkey) + privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Headers: nil, Bytes: privateKeyMarshaled}) + return string(privateKeyPEM) +} diff --git a/pkg/model/util_test.go b/pkg/model/util_test.go new file mode 100644 index 000000000..2e612f6da --- /dev/null +++ b/pkg/model/util_test.go @@ -0,0 +1,22 @@ +package model + +import ( + "testing" +) + +func Test_createSlug(t *testing.T) { + strings := map[string]string{ + "John Tyler": "john-tyler", + "James K. Polk": "james-k-polk", + "George H. W. Bush": "george-h-w-bush", + "François Hollande": "francois-hollande", + "dàzǒngtǒng": "dazongtong", + "大總統": "大總統", + } + + for k, v := range strings { + if slug := createSlug(k); slug != v { + t.Errorf("Expected Slug %s for string %s, got %s", v, k, slug) + } + } +} diff --git a/pkg/plugin/deploy/appfog.go b/pkg/plugin/deploy/appfog.go new file mode 100644 index 000000000..19e664922 --- /dev/null +++ b/pkg/plugin/deploy/appfog.go @@ -0,0 +1,12 @@ +package deploy + +import ( + "github.com/drone/drone/pkg/build/buildfile" +) + +type AppFog struct { +} + +func (a *AppFog) Write(f *buildfile.Buildfile) { + +} diff --git a/pkg/plugin/deploy/cloudcontrol.go b/pkg/plugin/deploy/cloudcontrol.go new file mode 100644 index 000000000..a9445de37 --- /dev/null +++ b/pkg/plugin/deploy/cloudcontrol.go @@ -0,0 +1,12 @@ +package deploy + +import ( + "github.com/drone/drone/pkg/build/buildfile" +) + +type CloudControl struct { +} + +func (c *CloudControl) Write(f *buildfile.Buildfile) { + +} diff --git a/pkg/plugin/deploy/cloudfoundry.go b/pkg/plugin/deploy/cloudfoundry.go new file mode 100644 index 000000000..b3203636b --- /dev/null +++ b/pkg/plugin/deploy/cloudfoundry.go @@ -0,0 +1,12 @@ +package deploy + +import ( + "github.com/drone/drone/pkg/build/buildfile" +) + +type CloudFoundry struct { +} + +func (c *CloudFoundry) Write(f *buildfile.Buildfile) { + +} diff --git a/pkg/plugin/deploy/deployment.go b/pkg/plugin/deploy/deployment.go new file mode 100644 index 000000000..71e79c718 --- /dev/null +++ b/pkg/plugin/deploy/deployment.go @@ -0,0 +1,42 @@ +package deploy + +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/plugin/deploy/engineyard.go b/pkg/plugin/deploy/engineyard.go new file mode 100644 index 000000000..2934f9038 --- /dev/null +++ b/pkg/plugin/deploy/engineyard.go @@ -0,0 +1,12 @@ +package deploy + +import ( + "github.com/drone/drone/pkg/build/buildfile" +) + +type EngineYard struct { +} + +func (e *EngineYard) Write(f *buildfile.Buildfile) { + +} diff --git a/pkg/plugin/deploy/git.go b/pkg/plugin/deploy/git.go new file mode 100644 index 000000000..b2b65d9ca --- /dev/null +++ b/pkg/plugin/deploy/git.go @@ -0,0 +1 @@ +package deploy diff --git a/pkg/plugin/deploy/heroku.go b/pkg/plugin/deploy/heroku.go new file mode 100644 index 000000000..4e4aa808d --- /dev/null +++ b/pkg/plugin/deploy/heroku.go @@ -0,0 +1,38 @@ +package deploy + +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/plugin/deploy/nodejitsu.go b/pkg/plugin/deploy/nodejitsu.go new file mode 100644 index 000000000..f1680009d --- /dev/null +++ b/pkg/plugin/deploy/nodejitsu.go @@ -0,0 +1,12 @@ +package deploy + +import ( + "github.com/drone/drone/pkg/build/buildfile" +) + +type Nodejitsu struct { +} + +func (n *Nodejitsu) Write(f *buildfile.Buildfile) { + +} diff --git a/pkg/plugin/deploy/openshift.go b/pkg/plugin/deploy/openshift.go new file mode 100644 index 000000000..7a25be254 --- /dev/null +++ b/pkg/plugin/deploy/openshift.go @@ -0,0 +1,12 @@ +package deploy + +import ( + "github.com/drone/drone/pkg/build/buildfile" +) + +type Openshift struct { +} + +func (o *Openshift) Write(f *buildfile.Buildfile) { + +} diff --git a/pkg/plugin/deploy/ssh.go b/pkg/plugin/deploy/ssh.go new file mode 100644 index 000000000..b2b65d9ca --- /dev/null +++ b/pkg/plugin/deploy/ssh.go @@ -0,0 +1 @@ +package deploy diff --git a/pkg/plugin/notify/email.go b/pkg/plugin/notify/email.go new file mode 100644 index 000000000..ed7b84759 --- /dev/null +++ b/pkg/plugin/notify/email.go @@ -0,0 +1,85 @@ +package notify + +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/plugin/notify/hipchat.go b/pkg/plugin/notify/hipchat.go new file mode 100644 index 000000000..e652c8e92 --- /dev/null +++ b/pkg/plugin/notify/hipchat.go @@ -0,0 +1,64 @@ +package notify + +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/plugin/notify/irc.go b/pkg/plugin/notify/irc.go new file mode 100644 index 000000000..a3131f139 --- /dev/null +++ b/pkg/plugin/notify/irc.go @@ -0,0 +1 @@ +package notify diff --git a/pkg/plugin/notify/notification.go b/pkg/plugin/notify/notification.go new file mode 100644 index 000000000..0b80ae4d8 --- /dev/null +++ b/pkg/plugin/notify/notification.go @@ -0,0 +1,53 @@ +package notify + +import ( + "github.com/drone/drone/pkg/model" +) + +// Context represents the context of an +// in-progress build request. +type Context struct { + // Global settings + Settings *model.Settings + + // 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/plugin/notify/webhook.go b/pkg/plugin/notify/webhook.go new file mode 100644 index 000000000..7dd23bb56 --- /dev/null +++ b/pkg/plugin/notify/webhook.go @@ -0,0 +1,59 @@ +package notify + +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/plugin/notify/zapier.go b/pkg/plugin/notify/zapier.go new file mode 100644 index 000000000..a3131f139 --- /dev/null +++ b/pkg/plugin/notify/zapier.go @@ -0,0 +1 @@ +package notify diff --git a/pkg/plugin/publish/bintray.go b/pkg/plugin/publish/bintray.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/plugin/publish/bintray.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/plugin/publish/dropbox.go b/pkg/plugin/publish/dropbox.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/plugin/publish/dropbox.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/plugin/publish/gems.go b/pkg/plugin/publish/gems.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/plugin/publish/gems.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/plugin/publish/maven.go b/pkg/plugin/publish/maven.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/plugin/publish/maven.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/plugin/publish/npm.go b/pkg/plugin/publish/npm.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/plugin/publish/npm.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/plugin/publish/pub.go b/pkg/plugin/publish/pub.go new file mode 100644 index 000000000..30b1a5b2a --- /dev/null +++ b/pkg/plugin/publish/pub.go @@ -0,0 +1 @@ +package publish diff --git a/pkg/plugin/publish/publish.go b/pkg/plugin/publish/publish.go new file mode 100644 index 000000000..d31088f29 --- /dev/null +++ b/pkg/plugin/publish/publish.go @@ -0,0 +1,18 @@ +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/plugin/publish/pypi.go b/pkg/plugin/publish/pypi.go new file mode 100644 index 000000000..d46cd60ca --- /dev/null +++ b/pkg/plugin/publish/pypi.go @@ -0,0 +1,2 @@ +package publish + diff --git a/pkg/plugin/publish/s3.go b/pkg/plugin/publish/s3.go new file mode 100644 index 000000000..cfa75e7d1 --- /dev/null +++ b/pkg/plugin/publish/s3.go @@ -0,0 +1,85 @@ +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/plugin/report/README.md b/pkg/plugin/report/README.md new file mode 100644 index 000000000..03260a5b1 --- /dev/null +++ b/pkg/plugin/report/README.md @@ -0,0 +1,5 @@ +cobertura.go +coveralls.go +gocov.go +junit.go +phpunit.go \ No newline at end of file diff --git a/pkg/queue/init.go b/pkg/queue/init.go new file mode 100644 index 000000000..c0b4b6972 --- /dev/null +++ b/pkg/queue/init.go @@ -0,0 +1,22 @@ +package queue + +import ( + "runtime" +) + +func init() { + // get the number of CPUs. Since builds + // tend to be CPU-intensive we should only + // execute 1 build per CPU. + ncpu := runtime.NumCPU() + + // must be at least 1 + if ncpu < 1 { + ncpu = 1 + } + + // spawn a worker for each CPU + for i := 0; i < ncpu; i++ { + go work() + } +} diff --git a/pkg/queue/queue.go b/pkg/queue/queue.go new file mode 100644 index 000000000..0f0fb7b7a --- /dev/null +++ b/pkg/queue/queue.go @@ -0,0 +1,284 @@ +package queue + +import ( + "bytes" + "fmt" + bldr "github.com/drone/drone/pkg/build" + 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/go-github/github" + "path/filepath" + "time" +) + +// queue that will store all build tasks until +// they are processed by a worker. +var queue = make(chan *BuildTask) + +// work is a function that will infinitely +// run in the background waiting for tasks that +// it can pull off the queue and execute. +func work() { + var task *BuildTask + for { + // get work item (pointer) from the queue + task = <-queue + if task == nil { + continue + } + + // execute the task + task.execute() + } +} + +// Add adds the task to the build queue. +func Add(task *BuildTask) { + queue <- task +} + +// BuildTasks represents a build that is pending +// execution. +type BuildTask struct { + Repo *Repo + Commit *Commit + Build *Build + + // Build instructions from the .drone.yml + // file, unmarshalled. + Script *script.Build +} + +// execute will execute the build task and persist +// the results to the datastore. +func (b *BuildTask) execute() error { + // we need to be sure that we can recover + // from any sort panic that could occur + // to avoid brining down the entire application + defer func() { + if e := recover(); e != nil { + b.Build.Finished = time.Now().UTC() + b.Commit.Finished = time.Now().UTC() + b.Build.Duration = b.Build.Finished.Unix() - b.Build.Started.Unix() + b.Commit.Duration = b.Build.Finished.Unix() - b.Build.Started.Unix() + b.Commit.Status = "Error" + b.Build.Status = "Error" + database.SaveBuild(b.Build) + database.SaveCommit(b.Commit) + } + }() + + // update commit and build status + b.Commit.Status = "Started" + b.Build.Status = "Started" + b.Build.Started = time.Now().UTC() + b.Commit.Started = time.Now().UTC() + + // persist the commit to the database + if err := database.SaveCommit(b.Commit); err != nil { + return err + } + + // persist the build to the database + if err := database.SaveBuild(b.Build); err != nil { + return err + } + + // get settings + settings, _ := database.GetSettings() + + // notification context + context := ¬ification.Context{ + Repo: b.Repo, + Commit: b.Commit, + Host: settings.URL().String(), + } + + // send all "started" notifications + if b.Script.Notifications != nil { + b.Script.Notifications.Send(context) + } + + // 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.Slug) + commitslug := fmt.Sprintf("%s/%s/%s/commit/%s", b.Repo.Host, b.Repo.Owner, b.Repo.Slug, b.Commit.Hash) + consoleslug := fmt.Sprintf("%s/%s/%s/commit/%s/builds/%s", b.Repo.Host, b.Repo.Owner, b.Repo.Slug, b.Commit.Hash, b.Build.Slug) + channel.Create(reposlug) + channel.Create(commitslug) + channel.CreateStream(consoleslug) + + // notify the channels that the commit and build started + channel.SendJSON(reposlug, b.Commit) + channel.SendJSON(commitslug, b.Build) + + var buf = &bufferWrapper{channel: consoleslug} + + // append private parameters to the environment + // variable section of the .drone.yml file + if b.Repo.Params != nil { + for k, v := range b.Repo.Params { + b.Script.Env = append(b.Script.Env, k+"="+v) + } + } + + // 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.Key = []byte(b.Repo.PrivateKey) + builder.Stdout = buf + builder.Timeout = 300 * time.Minute + buildErr := builder.Run() + + b.Build.Finished = time.Now().UTC() + b.Commit.Finished = time.Now().UTC() + b.Build.Duration = b.Build.Finished.UnixNano() - b.Build.Started.UnixNano() + b.Commit.Duration = b.Build.Finished.UnixNano() - b.Build.Started.UnixNano() + b.Commit.Status = "Success" + b.Build.Status = "Success" + b.Build.Stdout = buf.buf.String() + + // if exit code != 0 set to failure + if builder.BuildState == nil || builder.BuildState.ExitCode != 0 { + b.Commit.Status = "Failure" + b.Build.Status = "Failure" + if buildErr != nil && b.Build.Stdout == "" { + // TODO: If you wanted to have very friendly error messages, you could do that here + b.Build.Stdout = buildErr.Error() + "\n" + } + } + + // persist the build to the database + if err := database.SaveBuild(b.Build); err != nil { + return err + } + + // persist the commit to the database + if err := database.SaveCommit(b.Commit); err != nil { + return err + } + + // notify the channels that the commit and build finished + channel.SendJSON(reposlug, b.Commit) + 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 +} + +// updateGitHubStatus is a helper function that will send +// the build status to GitHub using the Status API. +// see https://github.com/blog/1227-commit-status-api +func updateGitHubStatus(repo *Repo, commit *Commit) error { + + // convert from drone status to github status + var message, status string + switch 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": + status = "pending" + message = "The build is pending on drone.io" + default: + status = "error" + message = "The build errored on drone.io" + } + + // get the system settings + settings, _ := database.GetSettings() + + // get the user from the database + // since we need his / her GitHub token + user, err := database.GetUser(repo.UserID) + if err == nil { + return err + } + + client := github.New(user.GithubToken) + return client.Repos.CreateStatus(repo.Owner, repo.Slug, status, settings.URL().String(), message, commit.Hash) +} + +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 + } + + 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.Slug, 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.Slug, email, c); err != nil { + return err + } + } + return nil +} + +type bufferWrapper struct { + buf bytes.Buffer + + // name of the channel + channel string +} + +func (b *bufferWrapper) Write(p []byte) (n int, err error) { + n, err = b.buf.Write(p) + channel.SendBytes(b.channel, p) + return +} diff --git a/pkg/template/emails/activation.html b/pkg/template/emails/activation.html new file mode 100644 index 000000000..9f5c5dc96 --- /dev/null +++ b/pkg/template/emails/activation.html @@ -0,0 +1,7 @@ +{{ define "title" }}ACCOUNT ACTIVATION{{ end }} + +{{ define "content" }} +

Please follow this link to activate your account: +

{{.Host}}/register?token={{.Token}}
+

+{{ end }} diff --git a/pkg/template/emails/base_email.html b/pkg/template/emails/base_email.html new file mode 100644 index 000000000..b12c5d7a1 --- /dev/null +++ b/pkg/template/emails/base_email.html @@ -0,0 +1,49 @@ + + + + + + + + Drone CI + + + + + + + +
+
+ + +
{{ template "title" . }} DRONE
+
+ + +
+
+ +
+ {{ template "content" . }} + + +

+ To learn more about Drone and all its features, check out our documentation: + http://drone.readthedocs.org/ +

+
+
+ \ No newline at end of file diff --git a/pkg/template/emails/failure.html b/pkg/template/emails/failure.html new file mode 100644 index 000000000..615d62c87 --- /dev/null +++ b/pkg/template/emails/failure.html @@ -0,0 +1,28 @@ +{{ define "title" }}FAILURE{{end}} + +{{ define "content" }} + +

+ Commit {{ .Commit.HashShort }} Failed +

+ +

{{ .Repo.Owner }} / {{ .Repo.Name }}

+ + + + + + + + + + + + + + + + + +
commit:{{ .Commit.HashShort }}
branch:{{ .Commit.Branch }}
author:{{ .Commit.Author }}
message:{{ .Commit.message }}
+{{ end }} \ No newline at end of file diff --git a/pkg/template/emails/invitation.html b/pkg/template/emails/invitation.html new file mode 100644 index 000000000..ab042797c --- /dev/null +++ b/pkg/template/emails/invitation.html @@ -0,0 +1,7 @@ +{{ define "title" }}TEAM INVITATION{{ end }} + +{{ define "content" }} +

You are invited to join team {{ .Team.Name }}. Please follow this link to join: +

{{.Host}}/accept?token={{.Token}}
+

+{{ end }} \ No newline at end of file diff --git a/pkg/template/emails/reset_password.html b/pkg/template/emails/reset_password.html new file mode 100644 index 000000000..6a54c5e23 --- /dev/null +++ b/pkg/template/emails/reset_password.html @@ -0,0 +1,7 @@ +{{ define "title" }}PASSWORD RESET{{ end }} + +{{ define "content" }} +

Please follow this link to activate your account: +

{{.Host}}/reset?token={{.Token}}
+

+{{ end }} \ No newline at end of file diff --git a/pkg/template/emails/success.html b/pkg/template/emails/success.html new file mode 100644 index 000000000..361bb3546 --- /dev/null +++ b/pkg/template/emails/success.html @@ -0,0 +1,28 @@ +{{ define "title" }}FAILURE{{end}} + +{{ define "content" }} + +

+ Commit {{ .Commit.HashShort }} Passed +

+ +

{{ .Repo.Owner }} / {{ .Repo.Name }}

+ + + + + + + + + + + + + + + + + +
commit:{{ .Commit.HashShort }}
branch:{{ .Commit.Branch }}
author:{{ .Commit.Author }}
message:{{ .Commit.message }}
+{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/403.html b/pkg/template/pages/403.html new file mode 100644 index 000000000..97dbb510d --- /dev/null +++ b/pkg/template/pages/403.html @@ -0,0 +1,9 @@ +{{ define "title" }}Forbidden · drone.io{{ end }} + +{{ define "content" }} +

Forbidden

+

Forbidden

+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/404.html b/pkg/template/pages/404.html new file mode 100644 index 000000000..b615a007a --- /dev/null +++ b/pkg/template/pages/404.html @@ -0,0 +1,9 @@ +{{ define "title" }}Not Found · drone.io{{ end }} + +{{ define "content" }} +

Oops!

+

We were unable to find the requested page.

+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/500.html b/pkg/template/pages/500.html new file mode 100644 index 000000000..e473901fd --- /dev/null +++ b/pkg/template/pages/500.html @@ -0,0 +1,9 @@ +{{ define "title" }}Bad Request · drone.io{{ end }} + +{{ define "content" }} +

Bad Request

+

Bad Request

+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/admin_settings.html b/pkg/template/pages/admin_settings.html new file mode 100644 index 000000000..76097d610 --- /dev/null +++ b/pkg/template/pages/admin_settings.html @@ -0,0 +1,114 @@ +{{ define "title" }}Profile{{ end }} + +{{ define "content" }} + +
+
+

Sysadmin

+
+
+ + +
+
+
+ +
+ +
+
+
+
Website Information
+ + + +
+
+
GitHub OAuth Consumer Key and Secret
+ +
+ + +
+
+
+
Bitbucket OAuth Consumer Key and Secret.
+ +
+ + +
+
+
+
SMTP Server Settings.
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+
+
+
+ + Cancel +
+
+
+
+ +
+{{ end }} + +{{ define "script" }} + + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/admin_users.html b/pkg/template/pages/admin_users.html new file mode 100644 index 000000000..5d8b50c39 --- /dev/null +++ b/pkg/template/pages/admin_users.html @@ -0,0 +1,53 @@ +{{ define "title" }}Users · Sysadmin{{ end }} + +{{ define "content" }} + +
+
+

Sysadmin

+
+
+ + +
+
+ +
+ +
+ +
+
Manage all users in the System.
+
+ + + + + +
+

add users

+
+
+
+ {{ range .Users }} + + + +
+

{{.Name}}

+
+
+
+ {{ end }} +
+
+
+ +
+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/admin_users_add.html b/pkg/template/pages/admin_users_add.html new file mode 100644 index 000000000..08d379fd1 --- /dev/null +++ b/pkg/template/pages/admin_users_add.html @@ -0,0 +1,78 @@ +{{ define "title" }}Settings · Sysadmin{{ end }} + +{{ define "content" }} + +
+
+

Sysadmin

+
+
+ + +
+
+ +
+ +
+ +
+
Users will be granted access by Email invitation.
+ +
+
+ +
+ +
+
+
+
+ + Cancel +
+
+
+ +
+
+ +
+{{ end }} + +{{ define "script" }} + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/admin_users_edit.html b/pkg/template/pages/admin_users_edit.html new file mode 100644 index 000000000..c82850ccc --- /dev/null +++ b/pkg/template/pages/admin_users_edit.html @@ -0,0 +1,103 @@ +{{ define "title" }}Settings · Sysadmin{{ end }} + +{{ define "content" }} + +
+
+

Sysadmin

+
+
+ + +
+
+ +
+ +
+ +
+
Edit the User Account Details
+
+
+
+ +
+ {{.EditUser.Name}} +
+ +
+ {{.EditUser.Email}} +
+ +
+ +
+
+
+
+ + Cancel +
+ +
+
+
+ + + +
+
+ +
+
+ +
+ Warning + this action cannot be undone. +
+
+
+
+ +
+{{ end }} + +{{ define "script" }} + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/base.html b/pkg/template/pages/base.html new file mode 100644 index 000000000..5af15d959 --- /dev/null +++ b/pkg/template/pages/base.html @@ -0,0 +1,56 @@ + + + + + + {{ template "title" . }} + + + + + + + + + + + + + + + + + + + + + + + {{ template "content" . }} + + + {{ template "script" . }} + + \ No newline at end of file diff --git a/pkg/template/pages/forgot.html b/pkg/template/pages/forgot.html new file mode 100644 index 000000000..d8b8235ad --- /dev/null +++ b/pkg/template/pages/forgot.html @@ -0,0 +1,17 @@ +{{ define "title" }}Forgot Password?{{ end }} + +{{ define "content" }} +

Forgot Password

+
+ +
+
+ +
+ +{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/forgot_sent.html b/pkg/template/pages/forgot_sent.html new file mode 100644 index 000000000..aa27bd88b --- /dev/null +++ b/pkg/template/pages/forgot_sent.html @@ -0,0 +1,18 @@ +{{ define "title" }}Forgot Password?{{ end }} + +{{ define "content" }} +

Forgot Password

+
A Password Reset Email was Sent.
+
+ +
+
+ +
+ +{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/form.html b/pkg/template/pages/form.html new file mode 100644 index 000000000..8e48aae38 --- /dev/null +++ b/pkg/template/pages/form.html @@ -0,0 +1,33 @@ + + + + + + {{ template "title" . }} + + + + + + + + + + + + + + + + +
+ {{ template "content" . }} +
+ + + + {{ template "script" . }} + + diff --git a/pkg/template/pages/github_add.html b/pkg/template/pages/github_add.html new file mode 100644 index 000000000..397bd2bbe --- /dev/null +++ b/pkg/template/pages/github_add.html @@ -0,0 +1,98 @@ +{{ define "title" }}GitHub · Add Repository{{ end }} + +{{ define "content" }} +
+
+

+ Repository Setup + GitHub +

+
+
+ +
+
+ + +
+
+ Enter your repository details + Re-Link Account +
+
+
+
+ +
+ +
+
+
+
/
+
+
+ +
+ +
+
+
+
+
Select your Drone account
+
    +
  • + + + Me +
  • + {{ range .Teams }} +
  • + + + {{ .Name }} +
  • + {{ end }} +
+
+
+
+ + Cancel +
+
+
+
+
+{{ end }} + +{{ define "script" }} + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/github_link.html b/pkg/template/pages/github_link.html new file mode 100644 index 000000000..1898f47f9 --- /dev/null +++ b/pkg/template/pages/github_link.html @@ -0,0 +1,30 @@ +{{ define "title" }}GitHub · Add Repository{{ end }} + +{{ define "content" }} +
+
+

+ Repository Setup + GitHub +

+
+
+ +
+
+ + +
+
Link Your GitHub Account + Link Now +
+
+
+
+{{ end }} + +{{ define "script" }}{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/install.html b/pkg/template/pages/install.html new file mode 100644 index 000000000..3a6c848eb --- /dev/null +++ b/pkg/template/pages/install.html @@ -0,0 +1,21 @@ +{{ define "title" }}Installation · drone.io{{ end }} + +{{ define "content" }} +

Installation

+
+ + + +
+
+ + + +
+{{ end }} + +{{ define "script" }} + +{{ end }} diff --git a/pkg/template/pages/login.html b/pkg/template/pages/login.html new file mode 100644 index 000000000..99b0ee5f1 --- /dev/null +++ b/pkg/template/pages/login.html @@ -0,0 +1,18 @@ +{{ define "title" }}Login · drone.io{{ end }} + +{{ define "content" }} +

Login

+
+ + +
+
+ +
+ +{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/login_error.html b/pkg/template/pages/login_error.html new file mode 100644 index 000000000..b5255d45d --- /dev/null +++ b/pkg/template/pages/login_error.html @@ -0,0 +1,19 @@ +{{ define "title" }}Login · drone.io{{ end }} + +{{ define "content" }} +

Login

+
Invalid Email Address or Password
+
+ + +
+
+ +
+ +{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/members_add.html b/pkg/template/pages/members_add.html new file mode 100644 index 000000000..f3e3e0333 --- /dev/null +++ b/pkg/template/pages/members_add.html @@ -0,0 +1,87 @@ +{{ define "title" }}{{.Team.Name}} · Profile{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.Team.Name}} +

+
+
+ + +
+
+ +
+ +
+ +
+
Invite a collaborator to join your Team.
+ +
+
+ +
+ +
+
+
+
+ + Cancel +
+
+
+
+
+ +
+{{ end }} + +{{ define "script" }} + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/members_edit.html b/pkg/template/pages/members_edit.html new file mode 100644 index 000000000..c5a915da8 --- /dev/null +++ b/pkg/template/pages/members_edit.html @@ -0,0 +1,110 @@ +{{ define "title" }}{{.Team.Name}} · Edit Member{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.Team.Slug}} +

+
+
+ + +
+
+ +
+ +
+ +
+
Edit Team Membership for {{.Member.Name}}
+
+
+
+ +
+ {{.Member.Name}} +
+ +
+ +
+
+
+
+ + Cancel +
+ +
+
+
+ + + +
+
+ +
+
+ +
+ Warning + this action cannot be undone. +
+
+
+
+
+ +
+{{ end }} + +{{ define "script" }} + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/register.html b/pkg/template/pages/register.html new file mode 100644 index 000000000..a9bc00855 --- /dev/null +++ b/pkg/template/pages/register.html @@ -0,0 +1,21 @@ +{{ define "title" }}Register · drone.io{{ end }} + +{{ define "content" }} +

Registration

+ {{ if .Error }} +
{{.Error}}
+ {{ end }} +
+ + +
+
+ + +
+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/repo_badges.html b/pkg/template/pages/repo_badges.html new file mode 100644 index 000000000..10d8a2acc --- /dev/null +++ b/pkg/template/pages/repo_badges.html @@ -0,0 +1,50 @@ +{{ define "title" }}{{.Repo.Slug}} · Badges{{ end }} + +{{ define "content" }} + +
+
+ +

+ {{.Repo.Name}} + {{.Repo.Owner}} +

+
+
+ + +
+
+ + + +
+
+
+ + + +
+
+ + +
+
+
+
+
+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/repo_commit.html b/pkg/template/pages/repo_commit.html new file mode 100644 index 000000000..93553a0d8 --- /dev/null +++ b/pkg/template/pages/repo_commit.html @@ -0,0 +1,80 @@ +{{ define "title" }}{{.Repo.Slug}} · {{ .Commit.HashShort }}{{ end }} + +{{ define "content" }} + +
+
+ +

+ {{.Repo.Name}} + {{.Repo.Owner}} +

+
+
+ +
+
+ + {{ if .Commit.PullRequest }} + opened pull request # {{ .Commit.PullRequest }} + {{ else }} + commit {{ .Commit.HashShort }} to {{.Commit.Branch}} branch + {{ end }} +
+
+
+
Status
+
{{.Build.Status}}
+
Started
+
+
Duration
+
{{ if .Build.IsRunning }}--{{else}}{{ .Build.HumanDuration }}{{end}}
+
+ +
+
Commit
+
{{ .Commit.HashShort }}
+
Committer
+
{{ .Commit.Author }}
+
Message
+
{{ .Commit.Message }}
+
+
+

+	
+{{ end }} + +{{ define "script" }} + + + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/repo_dashboard.html b/pkg/template/pages/repo_dashboard.html new file mode 100644 index 000000000..f54600739 --- /dev/null +++ b/pkg/template/pages/repo_dashboard.html @@ -0,0 +1,81 @@ +{{ define "title" }}{{.Repo.Slug}} · Dashboard{{ end }} + +{{ define "content" }} +
+
+ +

+ {{.Repo.Name}} + {{.Repo.Owner}} +

+
+
+ + {{ $repo := .Repo }} + {{ $branch := .Branch }} +
+
+
+ + + 0 new + + + {{ if .Commits }} +
    + {{ range .Commits }} +
  • + +

    + {{.HashShort}} + + {{ if .PullRequest }} +

    opened pull request # {{.PullRequest}}

    + {{ else }} +

    {{.Message}}  

    + {{ end }} +

    + +
  • + {{ end }} +
+ {{ end }} +
+ +
+ +
+
+
+{{ end }} + +{{ define "script" }} + + + + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/repo_delete.html b/pkg/template/pages/repo_delete.html new file mode 100644 index 000000000..d2ef37227 --- /dev/null +++ b/pkg/template/pages/repo_delete.html @@ -0,0 +1,54 @@ +{{ define "title" }}{{.Repo.Slug}} · Delete{{ end }} + +{{ define "content" }} + +
+
+ +

+ {{.Repo.Name}} + {{.Repo.Owner}} +

+
+
+ + +
+
+ + +
+
+ Delete this repository. + Warning: + this action cannot be undone. +
+ +
+ +
+ +
+
+ + Cancel +
+
+
+
+
+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/repo_keys.html b/pkg/template/pages/repo_keys.html new file mode 100644 index 000000000..5a4c505bd --- /dev/null +++ b/pkg/template/pages/repo_keys.html @@ -0,0 +1,45 @@ +{{ define "title" }}{{.Repo.Slug}} · Keys{{ end }} + +{{ define "content" }} + +
+
+ +

+ {{.Repo.Name}} + {{.Repo.Owner}} +

+
+
+ + +
+
+ + +
+
Public Key, used for Deployments
+
+ +
+ +
+
+
+
+
+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/repo_params.html b/pkg/template/pages/repo_params.html new file mode 100644 index 000000000..4b9d15e9f --- /dev/null +++ b/pkg/template/pages/repo_params.html @@ -0,0 +1,76 @@ +{{ define "title" }}{{.Repo.Slug}} · Secure{{ end }} + +{{ define "content" }} + +
+
+ +

+ {{.Repo.Name}} + {{.Repo.Owner}} +

+
+
+ + +
+
+ + +
+
Secure build configuration parameters
+
+ +
+ +
+
+
+
+ + Cancel +
+
+
+
+
+{{ end }} + +{{ define "script" }} + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/repo_settings.html b/pkg/template/pages/repo_settings.html new file mode 100644 index 000000000..7d54bbd34 --- /dev/null +++ b/pkg/template/pages/repo_settings.html @@ -0,0 +1,103 @@ +{{ define "title" }}{{.Repo.Slug}} · Settings{{ end }} + +{{ define "content" }} +
+
+ +

+ {{.Repo.Name}} + {{.Repo.Owner}} +

+
+
+ + +
+
+ + + +
+
Manage your repository settings.
+
+
+ +
+
+ +
+
Choose the account owner.
+
+ +
+
+
+
+ + Cancel +
+
+
+
+ +
+{{ end }} + +{{ define "script" }} + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/reset.html b/pkg/template/pages/reset.html new file mode 100644 index 000000000..97143ef46 --- /dev/null +++ b/pkg/template/pages/reset.html @@ -0,0 +1,20 @@ +{{ define "title" }}Reset · drone.io{{ end }} + +{{ define "content" }} +

Reset Password

+ {{ if .Error }} +
{{ .Error }}
+ {{ end }} +
+ +
+
+ +
+ +{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/team_dashboard.html b/pkg/template/pages/team_dashboard.html new file mode 100644 index 000000000..b6ebd5738 --- /dev/null +++ b/pkg/template/pages/team_dashboard.html @@ -0,0 +1,111 @@ +{{ define "title" }}{{.Team.Name}} · Dashboard{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.Team.Name}} +

+
+
+ +
+
+
+
+
    +
  • + + + +
  • + {{ range .Teams }} +
  • + + + +
  • + {{ end }} +
+ switch dashboard + Recent Builds +
+ + {{ if .Commits }} + + {{ end }} +
+ +
+ +
+
+
+{{ end }} + +{{ define "script" }} + + + + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/team_delete.html b/pkg/template/pages/team_delete.html new file mode 100644 index 000000000..68e077a6c --- /dev/null +++ b/pkg/template/pages/team_delete.html @@ -0,0 +1,57 @@ +{{ define "title" }}{{.Team.Name}} · Profile{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.Team.Name}} +

+
+
+ + +
+
+ +
+ +
+ +
+
Delete this Team account. Warning this action cannot be undone.
+ +
+
+ +
+ +
+
+ + Cancel +
+
+
+ +
+
+ +
+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/team_members.html b/pkg/template/pages/team_members.html new file mode 100644 index 000000000..3dc6cdfae --- /dev/null +++ b/pkg/template/pages/team_members.html @@ -0,0 +1,65 @@ +{{ define "title" }}{{.Team.Name}} · Profile{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.Team.Name}} +

+
+
+ + +
+
+ +
+ +
+ +
+
Members share access to all Repositories owned by this Team account.
+
+ + + + + +
+

add members

+
+
+
+ {{ $teamSlug := .Team.Slug }} + {{ range .Members }} + + + +
+

{{.Name}}

+
+
+
+ {{ end }} +
+
+
+
+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/team_profile.html b/pkg/template/pages/team_profile.html new file mode 100644 index 000000000..f6c35f04a --- /dev/null +++ b/pkg/template/pages/team_profile.html @@ -0,0 +1,94 @@ +{{ define "title" }}{{.Team.Name}} · Profile{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.Team.Name}} +

+
+
+ + +
+
+ +
+ +
+ +
+
Manage your team's Profile.
+ +
+
+ +
+ +
+ +
+ +
+
+
+
+ + Cancel +
+
+
+ + +
+
+ +
+{{ end }} + +{{ define "script" }} + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/user_dashboard.html b/pkg/template/pages/user_dashboard.html new file mode 100644 index 000000000..aba1dfafd --- /dev/null +++ b/pkg/template/pages/user_dashboard.html @@ -0,0 +1,110 @@ +{{ define "title" }}Dashboard{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.User.Name}} +

+
+
+ + +
+
+
+ +
+
    +
  • + + + +
  • + {{ range .Teams }} +
  • + + + +
  • + {{ end }} +
+ switch dashboard + Recent Builds +
+ + {{ if .Commits }} + + {{ end }} +
+ +
+ +
+
+ +
+{{ end }} + +{{ define "script" }} + + + +{{ end }} diff --git a/pkg/template/pages/user_delete.html b/pkg/template/pages/user_delete.html new file mode 100644 index 000000000..549c1ca2a --- /dev/null +++ b/pkg/template/pages/user_delete.html @@ -0,0 +1,57 @@ +{{ define "title" }}{{.User.Name}} · Profile{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.User.Name}} +

+
+
+ + +
+
+ +
+ +
+ +
+
Delete your account. Warning this action cannot be undone.
+
+
+ +
+ +
+
+ + Cancel +
+
+
+ +
+
+ +
+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/user_password.html b/pkg/template/pages/user_password.html new file mode 100644 index 000000000..161e47fd6 --- /dev/null +++ b/pkg/template/pages/user_password.html @@ -0,0 +1,84 @@ +{{ define "title" }}{{.User.Name}} · Password{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.User.Name}} +

+
+
+ + +
+
+ +
+ +
+ +
+
Change your account Password.
+
+
+ +
+ +
+
+
+
+ + Cancel +
+
+
+ +
+
+ +
+{{ end }} + +{{ define "script" }} + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/user_profile.html b/pkg/template/pages/user_profile.html new file mode 100644 index 000000000..9dd5a6b44 --- /dev/null +++ b/pkg/template/pages/user_profile.html @@ -0,0 +1,147 @@ +{{ define "title" }}{{.User.Name}} · Profile{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.User.Name}} +

+
+
+ + +
+
+ +
+ +
+ +
+
Manage your account Profile.
+ +
+
+ +
+ +
+ +
+ +
+
+
+
+ + Cancel +
+
+
+ + +
+
+ +
+{{ end }} + +{{ define "script" }} + +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/user_teams.html b/pkg/template/pages/user_teams.html new file mode 100644 index 000000000..b5a9de70f --- /dev/null +++ b/pkg/template/pages/user_teams.html @@ -0,0 +1,62 @@ +{{ define "title" }}{{.User.Name}} · Teams{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.User.Name}} +

+
+
+ + +
+
+ +
+ +
+ +
+
Teams allow you to share your Repositories with other developers.
+
+ + + + + +
+

add team

+
+
+
+ {{ range $team := .Teams }} + + + +
+

{{$team.Name}}

+
+
+
+ {{ end }} +
+
+
+ +
+{{ end }} + +{{ define "script" }} +{{ end }} \ No newline at end of file diff --git a/pkg/template/pages/user_teams_add.html b/pkg/template/pages/user_teams_add.html new file mode 100644 index 000000000..afa83fa4d --- /dev/null +++ b/pkg/template/pages/user_teams_add.html @@ -0,0 +1,85 @@ +{{ define "title" }}Profile{{ end }} + +{{ define "content" }} + +
+
+ +

+ + {{.User.Name}} +

+
+
+ + +
+
+ +
+ +
+ +
+
Setup a new Team Account.
+ +
+
+ +
+ +
+ +
+ +
+
+
+
+ + Cancel +
+
+
+ +
+
+ +
+{{ end }} + +{{ define "script" }} + +{{ end }} \ No newline at end of file diff --git a/pkg/template/template.go b/pkg/template/template.go new file mode 100644 index 000000000..76117728b --- /dev/null +++ b/pkg/template/template.go @@ -0,0 +1,142 @@ +package template + +import ( + "errors" + "html/template" + "io" + + "github.com/GeertJohan/go.rice" +) + +// ErrTemplateNotFound indicates the requested template +// does not exists in the TemplateStore. +var ErrTemplateNotFound = errors.New("Template Not Found") + +// registry stores a map of Templates where the key +// is the template name and the value is the *template.Template. +var registry = map[string]*template.Template{} + +// ExecuteTemplate applies the template associated with t that has +// the given name to the specified data object and writes the output to wr. +func ExecuteTemplate(wr io.Writer, name string, data interface{}) error { + templ, ok := registry[name] + if !ok { + return ErrTemplateNotFound + } + + return templ.ExecuteTemplate(wr, "_", data) +} + +// all template are loaded on initialization. +func init() { + // location of templates + box := rice.MustFindBox("pages") + + // these are all the files we need to parse. it is + // kind of annoying that we can't list files in the + // box, and have to enumerate each file here, but it is + // a small price to pay to embed everything and simplify + // the user installation process :) + var files = []string{ + // these templates use the form.html + // shared layout + "login.html", + "login_error.html", + "forgot.html", + "forgot_sent.html", + "reset.html", + "register.html", + "install.html", + + // these templates use the default.html + // shared layout + "403.html", + "404.html", + "500.html", + "user_dashboard.html", + "user_password.html", + "user_profile.html", + "user_delete.html", + "user_teams.html", + "user_teams_add.html", + "team_dashboard.html", + "team_profile.html", + "team_members.html", + "team_delete.html", + "members_add.html", + "members_edit.html", + "repo_dashboard.html", + "repo_settings.html", + "repo_delete.html", + "repo_params.html", + "repo_badges.html", + "repo_keys.html", + "repo_commit.html", + "admin_users.html", + "admin_users_edit.html", + "admin_users_add.html", + "admin_settings.html", + "github_add.html", + "github_link.html", + } + + // extract the base template as a string + base, err := box.String("base.html") + if err != nil { + panic(err) + } + + // extract the base form template as a string + form, err := box.String("form.html") + if err != nil { + panic(err) + } + + // loop through files and create templates + for i, file := range files { + // extract template from box + page, err := box.String(file) + if err != nil { + panic(err) + } + + // HACK: choose which base template to use FOR THE RECORD I + // don't really like this, but it works for now. + var baseTemplate = base + if i < 7 { + baseTemplate = form + } + + // parse the template and then add to the global map + registry[file] = template.Must(template.Must(template.New("_").Parse(baseTemplate)).Parse(page)) + } + + // location of templates + box = rice.MustFindBox("emails") + + files = []string{ + "activation.html", + "failure.html", + "success.html", + "invitation.html", + "reset_password.html", + } + + // extract the base template as a string + base, err = box.String("base_email.html") + if err != nil { + panic(err) + } + + // loop through files and create templates + for _, file := range files { + // extract template from box + email, err := box.String(file) + if err != nil { + panic(err) + } + + // parse the template and then add to the global map + registry[file] = template.Must(template.Must(template.New("_").Parse(base)).Parse(email)) + } +}