diff --git a/.travis.yml b/.travis.yml index 9552935f..7f4ddfe8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -69,5 +69,5 @@ jobs: file: inertia.* go: "1.10" on: - tags: true branch: master + repo: ubclaunchpad/inertia diff --git a/README.md b/README.md index 2e97dc77..5ac4bf35 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@
- Simple, self-hosted continuous deployment. + Effortless, self-hosted continuous deployment.
@@ -29,7 +29,7 @@ ---------------- -Inertia is a simple cross-platform command line application that enables effortless setup and management of continuous, automated deployment on any virtual private server. It is built and maintained with :heart: by [UBC Launch Pad](https://www.ubclaunchpad.com/). +Inertia is a simple cross-platform command line application that enables effortless setup and management of continuous, automated deployment all sorts of projects on any virtual private server. It is built and maintained with :heart: by [UBC Launch Pad](https://www.ubclaunchpad.com/).
@@ -49,24 +49,33 @@ Inertia is a simple cross-platform command line application that enables effortl
----------------
### Contents
-- **[Getting Started](#package-getting-started)**
+- [Getting Started](#package-getting-started)
- [Setup](#setup)
- [Continuous Deployment](#continuous-deployment)
- [Deployment Management](#deployment-management)
- [Release Streams](#release-streams)
-- **[Motivation and Design](#bulb-motivation-and-design)**
-- **[Contributing](#books-contributing)**
+- [Motivation and Design](#bulb-motivation-and-design)
+- [Contributing](#books-contributing)
# :package: Getting Started
-All you need to get started is a [compatible project](https://github.com/ubclaunchpad/inertia/wiki/Project-Compatibility), the Inertia CLI, and access to a virtual private server. The CLI can be installed using [Homebrew](https://brew.sh):
+All you need to get started is a [compatible project](https://github.com/ubclaunchpad/inertia/wiki/Project-Compatibility), the Inertia CLI, and access to a virtual private server.
+
+**MacOS** - the CLI can be installed using [Homebrew](https://brew.sh):
```bash
$> brew install ubclaunchpad/tap/inertia
```
+**Windows** - the CLI can be installed using [Scoop](http://scoop.sh):
+
+```bash
+$> scoop bucket add ubclaunchpad https://github.com/ubclaunchpad/scoop-bucket
+$> scoop install inertia
+```
+
For other platforms, you can [download the appropriate binary from the Releases page](https://github.com/ubclaunchpad/inertia/releases).
### Setup
@@ -179,6 +188,6 @@ Inertia is set up serverside by executing a script over SSH that installs Docker
Any contribution (pull requests, feedback, bug reports, ideas, etc.) is welcome!
-Please see our [contribution guide](https://github.com/ubclaunchpad/inertia/blob/master/.github/CONTRIBUTING.md) for contribution guidelines and a detailed guide to help you get started with Inertia's codebase.
+Please see our [contribution guide](https://github.com/ubclaunchpad/inertia/blob/master/.github/CONTRIBUTING.md) for contribution guidelines as well as a detailed guide to help you get started with Inertia's codebase.
diff --git a/daemon/inertia/project/build.go b/daemon/inertia/project/build.go
index 5a74e02e..c235a4a6 100644
--- a/daemon/inertia/project/build.go
+++ b/daemon/inertia/project/build.go
@@ -28,6 +28,10 @@ const (
BuildStageName = "build"
)
+// Builder builds projects and returns a callback that can be used to deploy the project.
+// No relation to Bob the Builder, though a Bob did write this.
+type Builder func(*Deployment, *docker.Client, io.Writer) (func() error, error)
+
// getTrueDirectory converts given filepath to host-based filepath
// if applicable - Docker commands are sent to the mounted Docker
// socket and hence are executed on the host, using the host's filepaths,
@@ -51,7 +55,7 @@ func getTrueDirectory(path string) string {
// separate from the daemon and the user's project, and is the
// second container to require access to the docker socket.
// See https://cloud.google.com/community/tutorials/docker-compose-on-container-optimized-os
-func dockerCompose(d *Deployment, cli *docker.Client, out io.Writer) error {
+func dockerCompose(d *Deployment, cli *docker.Client, out io.Writer) (func() error, error) {
fmt.Fprintln(out, "Setting up docker-compose...")
ctx := context.Background()
@@ -73,19 +77,19 @@ func dockerCompose(d *Deployment, cli *docker.Client, out io.Writer) error {
}, nil, BuildStageName,
)
if err != nil {
- return err
+ return nil, err
}
if len(resp.Warnings) > 0 {
fmt.Fprintln(out, "Warnings encountered on docker-compose build.")
warnings := strings.Join(resp.Warnings, "\n")
- return errors.New(warnings)
+ return nil, errors.New(warnings)
}
// Start container to build project
fmt.Fprintln(out, "Building project...")
err = cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
if err != nil {
- return err
+ return nil, err
}
// Attach logs and report build progress until container exits
@@ -94,7 +98,7 @@ func dockerCompose(d *Deployment, cli *docker.Client, out io.Writer) error {
NoTimestamps: true,
})
if err != nil {
- return err
+ return nil, err
}
stop := make(chan struct{})
go common.FlushRoutine(out, reader, stop)
@@ -102,10 +106,10 @@ func dockerCompose(d *Deployment, cli *docker.Client, out io.Writer) error {
close(stop)
reader.Close()
if err != nil {
- return err
+ return nil, err
}
if status != 0 {
- return errors.New("Build exited with non-zero status: " + strconv.FormatInt(status, 10))
+ return nil, errors.New("Build exited with non-zero status: " + strconv.FormatInt(status, 10))
}
fmt.Fprintln(out, "Build exited with status "+strconv.FormatInt(status, 10))
@@ -135,25 +139,27 @@ func dockerCompose(d *Deployment, cli *docker.Client, out io.Writer) error {
}, nil, "docker-compose",
)
if err != nil {
- return err
+ return nil, err
}
if len(resp.Warnings) > 0 {
warnings := strings.Join(resp.Warnings, "\n")
- return errors.New(warnings)
+ return nil, errors.New(warnings)
}
- fmt.Fprintln(out, "Starting up project...")
- return cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
+ return func() error {
+ fmt.Fprintln(out, "Starting up project...")
+ return cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
+ }, nil
}
-// dockerBuild builds project from Dockerfile and deploys it
-func dockerBuild(d *Deployment, cli *docker.Client, out io.Writer) error {
+// dockerBuild builds project from Dockerfile, and returns a callback function to deploy it
+func dockerBuild(d *Deployment, cli *docker.Client, out io.Writer) (func() error, error) {
fmt.Fprintln(out, "Building Dockerfile project...")
ctx := context.Background()
buildCtx := bytes.NewBuffer(nil)
err := common.BuildTar(d.directory, buildCtx)
if err != nil {
- return err
+ return nil, err
}
// @TODO: support configuration
@@ -170,7 +176,7 @@ func dockerBuild(d *Deployment, cli *docker.Client, out io.Writer) error {
},
)
if err != nil {
- return err
+ return nil, err
}
// Output build progress
@@ -192,22 +198,24 @@ func dockerBuild(d *Deployment, cli *docker.Client, out io.Writer) error {
)
if err != nil {
if strings.Contains(err.Error(), "No such image") {
- return errors.New("Image build was unsuccessful")
+ return nil, errors.New("Image build was unsuccessful")
}
- return err
+ return nil, err
}
if len(containerResp.Warnings) > 0 {
warnings := strings.Join(containerResp.Warnings, "\n")
- return errors.New(warnings)
+ return nil, errors.New(warnings)
}
- fmt.Fprintln(out, "Starting up project in container "+d.project+"...")
- return cli.ContainerStart(ctx, containerResp.ID, types.ContainerStartOptions{})
+ return func() error {
+ fmt.Fprintln(out, "Starting up project in container "+d.project+"...")
+ return cli.ContainerStart(ctx, containerResp.ID, types.ContainerStartOptions{})
+ }, nil
}
// herokuishBuild uses the Herokuish tool to use Heroku's official buidpacks
// to build the user project.
-func herokuishBuild(d *Deployment, cli *docker.Client, out io.Writer) error {
+func herokuishBuild(d *Deployment, cli *docker.Client, out io.Writer) (func() error, error) {
fmt.Fprintln(out, "Setting up herokuish...")
ctx := context.Background()
@@ -226,19 +234,19 @@ func herokuishBuild(d *Deployment, cli *docker.Client, out io.Writer) error {
}, nil, BuildStageName,
)
if err != nil {
- return err
+ return nil, err
}
if len(resp.Warnings) > 0 {
fmt.Fprintln(out, "Warnings encountered on herokuish setup.")
warnings := strings.Join(resp.Warnings, "\n")
- return errors.New(warnings)
+ return nil, errors.New(warnings)
}
// Start the herokuish container to build project
fmt.Fprintln(out, "Building project...")
err = cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
if err != nil {
- return err
+ return nil, err
}
// Attach logs and report build progress until container exits
@@ -247,7 +255,7 @@ func herokuishBuild(d *Deployment, cli *docker.Client, out io.Writer) error {
NoTimestamps: true,
})
if err != nil {
- return err
+ return nil, err
}
stop := make(chan struct{})
go common.FlushRoutine(out, reader, stop)
@@ -255,10 +263,10 @@ func herokuishBuild(d *Deployment, cli *docker.Client, out io.Writer) error {
close(stop)
reader.Close()
if err != nil {
- return err
+ return nil, err
}
if status != 0 {
- return errors.New("Build exited with non-zero status: " + strconv.FormatInt(status, 10))
+ return nil, errors.New("Build exited with non-zero status: " + strconv.FormatInt(status, 10))
}
fmt.Fprintln(out, "Build exited with status "+strconv.FormatInt(status, 10))
@@ -269,7 +277,7 @@ func herokuishBuild(d *Deployment, cli *docker.Client, out io.Writer) error {
Reference: imgName,
})
if err != nil {
- return err
+ return nil, err
}
resp, err = cli.ContainerCreate(ctx, &container.Config{
Image: imgName + ":latest",
@@ -278,14 +286,16 @@ func herokuishBuild(d *Deployment, cli *docker.Client, out io.Writer) error {
Cmd: []string{"/start", "web"},
}, nil, nil, d.project)
if err != nil {
- return err
+ return nil, err
}
if len(resp.Warnings) > 0 {
fmt.Fprintln(out, "Warnings encountered on herokuish startup.")
warnings := strings.Join(resp.Warnings, "\n")
- return errors.New(warnings)
+ return nil, errors.New(warnings)
}
fmt.Fprintln(out, "Starting up project in container "+d.project+"...")
- return cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
+ return func() error {
+ return cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
+ }, nil
}
diff --git a/daemon/inertia/project/build_test.go b/daemon/inertia/project/build_test.go
index 6bc3602b..e3e23f75 100644
--- a/daemon/inertia/project/build_test.go
+++ b/daemon/inertia/project/build_test.go
@@ -2,6 +2,7 @@ package project
import (
"context"
+ "io"
"os"
"path"
"strings"
@@ -14,7 +15,8 @@ import (
"github.com/stretchr/testify/assert"
)
-func cleanupContainers(cli *docker.Client) error {
+// killTestContainers is a helper for tests - it implements project.ContainerStopper
+func killTestContainers(cli *docker.Client, w io.Writer) error {
ctx := context.Background()
containers, err := cli.ContainerList(ctx, types.ContainerListOptions{})
if err != nil {
@@ -45,7 +47,7 @@ func TestDockerComposeIntegration(t *testing.T) {
defer cli.Close()
// Set up
- err = cleanupContainers(cli)
+ err = killTestContainers(cli, nil)
assert.Nil(t, err)
testProjectDir := path.Join(
@@ -57,10 +59,21 @@ func TestDockerComposeIntegration(t *testing.T) {
directory: testProjectDir,
project: testProjectName,
buildType: "docker-compose",
+
+ builders: map[string]Builder{
+ "herokuish": herokuishBuild,
+ "dockerfile": dockerBuild,
+ "docker-compose": dockerCompose,
+ },
+ containerStopper: killTestContainers,
}
// Execute build
- err = dockerCompose(d, cli, os.Stdout)
+ deploy, err := dockerCompose(d, cli, os.Stdout)
+ assert.Nil(t, err)
+
+ // Execute deploy
+ err = deploy()
assert.Nil(t, err)
// Arbitrary wait for containers to start
@@ -101,7 +114,46 @@ func TestDockerComposeIntegration(t *testing.T) {
assert.True(t, foundDC, "docker-compose container should be active")
assert.True(t, foundP, "project container should be active")
- err = cleanupContainers(cli)
+ // Attempt another deploy using Deployment
+ err = d.Deploy(cli, os.Stdout, DeployOptions{SkipUpdate: true})
+ assert.Nil(t, err)
+
+ // Arbitrary wait for containers to start again
+ time.Sleep(5 * time.Second)
+
+ // Check for containers
+ containers, err = cli.ContainerList(
+ context.Background(),
+ types.ContainerListOptions{},
+ )
+ assert.Nil(t, err)
+ foundDC = false
+ foundP = false
+ for _, c := range containers {
+ if strings.Contains(c.Names[0], "docker-compose") {
+ foundDC = true
+ }
+ if strings.Contains(c.Names[0], testProjectName) {
+ foundP = true
+ }
+ }
+
+ // try again if project no up (workaround for Travis)
+ if !foundP {
+ time.Sleep(10 * time.Second)
+ containers, err = cli.ContainerList(
+ context.Background(),
+ types.ContainerListOptions{},
+ )
+ assert.Nil(t, err)
+ for _, c := range containers {
+ if strings.Contains(c.Names[0], testProjectName) {
+ foundP = true
+ }
+ }
+ }
+
+ err = killTestContainers(cli, nil)
assert.Nil(t, err)
}
@@ -113,7 +165,7 @@ func TestDockerBuildIntegration(t *testing.T) {
assert.Nil(t, err)
defer cli.Close()
- err = cleanupContainers(cli)
+ err = killTestContainers(cli, nil)
assert.Nil(t, err)
testProjectDir := path.Join(
@@ -125,10 +177,21 @@ func TestDockerBuildIntegration(t *testing.T) {
directory: testProjectDir,
project: testProjectName,
buildType: "dockerfile",
+
+ builders: map[string]Builder{
+ "herokuish": herokuishBuild,
+ "dockerfile": dockerBuild,
+ "docker-compose": dockerCompose,
+ },
+ containerStopper: killTestContainers,
}
// Execute build
- err = dockerBuild(d, cli, os.Stdout)
+ deploy, err := dockerBuild(d, cli, os.Stdout)
+ assert.Nil(t, err)
+
+ // Execute deploy
+ err = deploy()
assert.Nil(t, err)
// Arbitrary wait for containers to start
@@ -147,7 +210,27 @@ func TestDockerBuildIntegration(t *testing.T) {
}
assert.True(t, foundP, "project container should be active")
- err = cleanupContainers(cli)
+ // Attempt another deploy using Deployment
+ err = d.Deploy(cli, os.Stdout, DeployOptions{SkipUpdate: true})
+ assert.Nil(t, err)
+
+ // Arbitrary wait for containers to start
+ time.Sleep(5 * time.Second)
+
+ containers, err = cli.ContainerList(
+ context.Background(),
+ types.ContainerListOptions{},
+ )
+ assert.Nil(t, err)
+ foundP = false
+ for _, c := range containers {
+ if strings.Contains(c.Names[0], testProjectName) {
+ foundP = true
+ }
+ }
+ assert.True(t, foundP, "project container should be active")
+
+ err = killTestContainers(cli, nil)
assert.Nil(t, err)
}
@@ -159,7 +242,7 @@ func TestHerokuishBuildIntegration(t *testing.T) {
assert.Nil(t, err)
defer cli.Close()
- err = cleanupContainers(cli)
+ err = killTestContainers(cli, nil)
assert.Nil(t, err)
testProjectDir := path.Join(
@@ -171,10 +254,21 @@ func TestHerokuishBuildIntegration(t *testing.T) {
directory: testProjectDir,
project: testProjectName,
buildType: "herokuish",
+
+ builders: map[string]Builder{
+ "herokuish": herokuishBuild,
+ "dockerfile": dockerBuild,
+ "docker-compose": dockerCompose,
+ },
+ containerStopper: killTestContainers,
}
// Execute build
- err = herokuishBuild(d, cli, os.Stdout)
+ deploy, err := herokuishBuild(d, cli, os.Stdout)
+ assert.Nil(t, err)
+
+ // Execute deploy
+ err = deploy()
assert.Nil(t, err)
// Arbitrary wait for containers to start
@@ -193,6 +287,26 @@ func TestHerokuishBuildIntegration(t *testing.T) {
}
assert.True(t, foundP, "project container should be active")
- err = cleanupContainers(cli)
+ // Attempt another deploy using Deployment
+ err = d.Deploy(cli, os.Stdout, DeployOptions{SkipUpdate: true})
+ assert.Nil(t, err)
+
+ // Arbitrary wait for containers to start
+ time.Sleep(5 * time.Second)
+
+ containers, err = cli.ContainerList(
+ context.Background(),
+ types.ContainerListOptions{},
+ )
+ assert.Nil(t, err)
+ foundP = false
+ for _, c := range containers {
+ if strings.Contains(c.Names[0], testProjectName) {
+ foundP = true
+ }
+ }
+ assert.True(t, foundP, "project container should be active")
+
+ err = killTestContainers(cli, nil)
assert.Nil(t, err)
}
diff --git a/daemon/inertia/project/deployment.go b/daemon/inertia/project/deployment.go
index 37827267..05571cc7 100644
--- a/daemon/inertia/project/deployment.go
+++ b/daemon/inertia/project/deployment.go
@@ -35,6 +35,9 @@ type Deployment struct {
branch string
buildType string
+ builders map[string]Builder
+ containerStopper
+
repo *git.Repository
auth ssh.AuthMethod
mux sync.Mutex
@@ -68,12 +71,23 @@ func NewDeployment(cfg DeploymentConfig, out io.Writer) (*Deployment, error) {
}
return &Deployment{
+ // Properties
directory: directory,
project: cfg.ProjectName,
branch: cfg.Branch,
buildType: cfg.BuildType,
- auth: authMethod,
- repo: repo,
+
+ // Functions
+ builders: map[string]Builder{
+ "herokuish": herokuishBuild,
+ "dockerfile": dockerBuild,
+ "docker-compose": dockerCompose,
+ },
+ containerStopper: stopActiveContainers,
+
+ // Repository
+ auth: authMethod,
+ repo: repo,
}, nil
}
@@ -110,25 +124,28 @@ func (d *Deployment) Deploy(cli *docker.Client, out io.Writer, opts DeployOption
}
}
+ // Use the appropriate build method
+ builder, found := d.builders[strings.ToLower(d.buildType)]
+ if !found {
+ // @todo: attempt a guess at project type instead
+ fmt.Println(out, "Unknown project type "+d.buildType)
+ fmt.Println(out, "Defaulting to docker-compose build")
+ builder = dockerCompose
+ }
+
// Kill active project containers if there are any
- err := stopActiveContainers(cli, out)
+ err := d.containerStopper(cli, out)
if err != nil {
return err
}
- // Use the appropriate build method
- switch d.buildType {
- case "herokuish":
- return herokuishBuild(d, cli, out)
- case "dockerfile":
- return dockerBuild(d, cli, out)
- case "docker-compose":
- return dockerCompose(d, cli, out)
- default:
- fmt.Println(out, "Unknown project type "+d.buildType)
- fmt.Println(out, "Defaulting to docker-compose build")
- return dockerCompose(d, cli, out)
+ // Deploy project
+ deploy, err := builder(d, cli, out)
+ if err != nil {
+ return err
}
+
+ return deploy()
}
// Down shuts down the deployment
@@ -141,13 +158,13 @@ func (d *Deployment) Down(cli *docker.Client, out io.Writer) error {
// active
_, err := getActiveContainers(cli)
if err != nil {
- killErr := stopActiveContainers(cli, out)
+ killErr := d.containerStopper(cli, out)
if killErr != nil {
println(err)
}
return err
}
- return stopActiveContainers(cli, out)
+ return d.containerStopper(cli, out)
}
// Destroy shuts down the deployment and removes the repository
diff --git a/daemon/inertia/project/deployment_test.go b/daemon/inertia/project/deployment_test.go
index dddb235f..a787b8bf 100644
--- a/daemon/inertia/project/deployment_test.go
+++ b/daemon/inertia/project/deployment_test.go
@@ -1,10 +1,14 @@
package project
import (
+ "io"
+ "os"
"testing"
"github.com/stretchr/testify/assert"
git "gopkg.in/src-d/go-git.v4"
+
+ docker "github.com/docker/docker/client"
)
func TestSetConfig(t *testing.T) {
@@ -20,6 +24,90 @@ func TestSetConfig(t *testing.T) {
assert.Equal(t, "best", deployment.buildType)
}
+func TestDeployMockSkipUpdateIntegration(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
+
+ buildCalled := false
+ stopCalled := false
+ d := Deployment{
+ directory: "./test/",
+ buildType: "test",
+ builders: map[string]Builder{
+ "test": func(*Deployment, *docker.Client, io.Writer) (func() error, error) {
+ return func() error {
+ buildCalled = true
+ return nil
+ }, nil
+ },
+ },
+ containerStopper: func(*docker.Client, io.Writer) error {
+ stopCalled = true
+ return nil
+ },
+ }
+
+ cli, err := docker.NewEnvClient()
+ assert.Nil(t, err)
+ defer cli.Close()
+
+ err = d.Deploy(cli, os.Stdout, DeployOptions{SkipUpdate: true})
+ assert.Nil(t, err)
+ assert.True(t, buildCalled)
+ assert.True(t, stopCalled)
+}
+
+func TestDownIntegration(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
+
+ called := false
+ d := Deployment{
+ directory: "./test/",
+ buildType: "test",
+ containerStopper: func(*docker.Client, io.Writer) error {
+ called = true
+ return nil
+ },
+ }
+
+ cli, err := docker.NewEnvClient()
+ assert.Nil(t, err)
+ defer cli.Close()
+
+ err = d.Down(cli, os.Stdout)
+ if err != ErrNoContainers {
+ assert.Nil(t, err)
+ }
+
+ assert.True(t, called)
+}
+
+func TestGetStatusIntegration(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
+
+ // Traverse back down to root directory of repository
+ repo, err := git.PlainOpen("../../../")
+ assert.Nil(t, err)
+
+ cli, err := docker.NewEnvClient()
+ assert.Nil(t, err)
+ defer cli.Close()
+
+ deployment := &Deployment{
+ repo: repo,
+ buildType: "test",
+ }
+ status, err := deployment.GetStatus(cli)
+ assert.Nil(t, err)
+ assert.False(t, status.BuildContainerActive)
+ assert.Equal(t, "test", status.BuildType)
+}
+
func TestGetBranch(t *testing.T) {
deployment := &Deployment{branch: "master"}
assert.Equal(t, "master", deployment.GetBranch())
diff --git a/daemon/inertia/project/docker.go b/daemon/inertia/project/docker.go
index bef7a742..be3f847e 100644
--- a/daemon/inertia/project/docker.go
+++ b/daemon/inertia/project/docker.go
@@ -58,6 +58,8 @@ func getActiveContainers(cli *docker.Client) ([]types.Container, error) {
return containers, nil
}
+type containerStopper func(*docker.Client, io.Writer) error
+
// stopActiveContainers kills all active project containers (ie not including daemon)
func stopActiveContainers(cli *docker.Client, out io.Writer) error {
fmt.Fprintln(out, "Shutting down active containers...")