├── coverage └── .gitkeep ├── .version ├── fixtures ├── emptymanifest.yml ├── custom-manifest.yml ├── manifest.yml ├── base-manifest.yml └── manifestwithinheritance.yml ├── test └── support │ ├── nonexec-smoke-test-script │ └── smoke-test-script ├── .releaseDescription ├── script ├── ci │ ├── concourse │ │ ├── credentials.sample.yml │ │ ├── acceptance.yml │ │ ├── build.yml │ │ ├── ci_acceptance │ │ ├── ci_build │ │ └── pipeline.yml │ └── bluemix-devops │ │ ├── ci_env │ │ ├── ci_publish │ │ ├── ci_acceptance │ │ ├── pipeline.yml │ │ ├── ci_build │ │ └── ci_release ├── dep_graph ├── install ├── clear_cf_space ├── deps ├── build ├── with_env ├── test ├── common └── test_acceptance ├── acceptance ├── app │ ├── start │ ├── script │ │ └── smoke_test │ └── manifest.yml └── app-with-strangely-named-manifest │ ├── start │ ├── script │ └── smoke_test │ └── strangely-named-manifest.yml ├── .gitignore ├── cf_green_blue_deploy_suite_test.go ├── manifest ├── manifest_suite_test.go ├── fakes │ └── fake_manifest_reader.go ├── manifest_reader_test.go ├── merge_reduce_test.go ├── manifest_reader.go ├── merge_reduce.go ├── manifest_test.go └── manifest.go ├── .env ├── args.go ├── CONTRIBUTING.md ├── README.md ├── release.md ├── args_test.go ├── main.go ├── blue_green_deploy.go ├── LICENSE ├── blue_green_deploy_test.go └── main_test.go /coverage/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.version: -------------------------------------------------------------------------------- 1 | 1.5.0-rc1 2 | -------------------------------------------------------------------------------- /fixtures/emptymanifest.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/support/nonexec-smoke-test-script: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fixtures/custom-manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: my-app -------------------------------------------------------------------------------- /fixtures/manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: plain-app -------------------------------------------------------------------------------- /.releaseDescription: -------------------------------------------------------------------------------- 1 | New release description here 2 | -------------------------------------------------------------------------------- /fixtures/base-manifest.yml: -------------------------------------------------------------------------------- 1 | --- 2 | domain: shared-domain.example.com -------------------------------------------------------------------------------- /fixtures/manifestwithinheritance.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit: base-manifest.yml 3 | name: fancy-app -------------------------------------------------------------------------------- /script/ci/concourse/credentials.sample.yml: -------------------------------------------------------------------------------- 1 | cf-password: ... 2 | swift-password: ... 3 | swift-username: ... 4 | -------------------------------------------------------------------------------- /acceptance/app/start: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluemixgaragelondon/cf-blue-green-deploy/HEAD/acceptance/app/start -------------------------------------------------------------------------------- /script/ci/bluemix-devops/ci_env: -------------------------------------------------------------------------------- 1 | export LOGICAL_APP_NAME="Blue Green Deploy plugin" 2 | export BUILD_PREFIX="master" 3 | export LOGICAL_ENV_NAME="sandbox" 4 | -------------------------------------------------------------------------------- /acceptance/app-with-strangely-named-manifest/start: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bluemixgaragelondon/cf-blue-green-deploy/HEAD/acceptance/app-with-strangely-named-manifest/start -------------------------------------------------------------------------------- /acceptance/app/script/smoke_test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | app_fqdn="$1" 4 | 5 | [[ "$app_fqdn" =~ FORCE-SMOKE-TEST-FAILURE ]] && exit 1 6 | 7 | grep "Hello world from my Go program!" <<< "$(curl -svL "$app_fqdn")" 8 | -------------------------------------------------------------------------------- /script/dep_graph: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | go_project=cf-blue-green-deploy 4 | graph_file="/tmp/${go_project}_dep_graph.png" 5 | 6 | goviz -i "github.com/bluemixgaragelondon/$go_project" | dot -Tpng -o "$graph_file" 7 | open "$graph_file" 8 | -------------------------------------------------------------------------------- /test/support/smoke-test-script: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | app_fqdn="$1" 4 | 5 | [[ "$app_fqdn" =~ FORCE-SMOKE-TEST-FAILURE ]] && exit 1 6 | 7 | echo "STDOUT" 8 | echo "STDERR" >&2 9 | 10 | printf "App FQDN is: %s" "$app_fqdn" 11 | -------------------------------------------------------------------------------- /acceptance/app-with-strangely-named-manifest/script/smoke_test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | app_fqdn="$1" 4 | 5 | [[ "$app_fqdn" =~ FORCE-SMOKE-TEST-FAILURE ]] && exit 1 6 | 7 | grep "Hello world from my Go program!" <<< "$(curl -svL "$app_fqdn")" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cf/ 2 | cf-blue-green-deploy 3 | coverage/*.html 4 | blue-green-deploy.* 5 | artefacts/ 6 | Godeps/_workspace 7 | test-results/ 8 | .idea/ 9 | junit.xml 10 | manifest/manifest-junit.xml 11 | .envrc 12 | cf-blue-green-deploy.coverprofile 13 | -------------------------------------------------------------------------------- /acceptance/app/manifest.yml: -------------------------------------------------------------------------------- 1 | name: cf-blue-green-deploy-test-app 2 | command: ./start $PORT 3 | memory: 8M 4 | buildpack: https://github.com/ph3nx/heroku-binary-buildpack.git 5 | hosts: 6 | - bgd1 7 | - bgd2 8 | domains: 9 | - 5.10.124.141.xip.io 10 | - bluegreen.ibm 11 | -------------------------------------------------------------------------------- /script/install: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # vim: set ft=sh 3 | 4 | . script/with_env 5 | . script/common 6 | 7 | PLUGIN_NAME="${PLUGIN_NAME:?Must be defined in .env}" 8 | 9 | main() { 10 | uninstall_plugin "$PLUGIN_NAME" 11 | install_plugin "$PLUGIN_NAME" 12 | } 13 | 14 | main 15 | -------------------------------------------------------------------------------- /acceptance/app-with-strangely-named-manifest/strangely-named-manifest.yml: -------------------------------------------------------------------------------- 1 | name: cf-blue-green-deploy-test-app 2 | command: ./start $PORT 3 | memory: 8M 4 | buildpack: https://github.com/ph3nx/heroku-binary-buildpack.git 5 | hosts: 6 | - bgd1 7 | - bgd2 8 | domains: 9 | - bluegreen.ibm 10 | - 5.10.124.141.xip.io 11 | -------------------------------------------------------------------------------- /script/clear_cf_space: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | main() { 4 | . script/with_env 5 | . script/common 6 | 7 | pushd acceptance/app >/dev/null 8 | login_to_bluemix 9 | cf apps | awk '/cf-blue-green-deploy-test-app/ { system("cf d -f -r " $1) }' 10 | cf delete-orphaned-routes -f 11 | popd >/dev/null 12 | } 13 | 14 | main 15 | -------------------------------------------------------------------------------- /script/ci/concourse/acceptance.yml: -------------------------------------------------------------------------------- 1 | --- 2 | platform: linux 3 | image_resource: 4 | type: docker-image 5 | source: {repository: viniciusffj/docker-cf-cli} 6 | inputs: 7 | - name: bgd-git 8 | path: src/github.com/bluemixgaragelondon/cf-blue-green-deploy 9 | - name: bgd-artefact-swift 10 | run: 11 | path: src/github.com/bluemixgaragelondon/cf-blue-green-deploy/script/ci/concourse/ci_acceptance 12 | -------------------------------------------------------------------------------- /script/ci/concourse/build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | platform: linux 3 | image_resource: 4 | type: docker-image 5 | source: {repository: golang, tag: "1.5.4"} 6 | inputs: 7 | - name: bgd-git 8 | path: src/github.com/bluemixgaragelondon/cf-blue-green-deploy 9 | - name: version 10 | outputs: 11 | - name: artefacts 12 | run: 13 | path: src/github.com/bluemixgaragelondon/cf-blue-green-deploy/script/ci/ci_build 14 | -------------------------------------------------------------------------------- /script/ci/concourse/ci_acceptance: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -x 3 | 4 | pushd src/github.com/bluemixgaragelondon/cf-blue-green-deploy 5 | . script/with_env 6 | popd 7 | 8 | pushd bgd-artefact-swift 9 | tar -xvf "$(cat filename)" 10 | cf install-plugin "${PLUGIN_NAME}.linux64" <<< "y" 11 | popd 12 | 13 | pushd src/github.com/bluemixgaragelondon/cf-blue-green-deploy 14 | ./script/test_acceptance 15 | popd 16 | -------------------------------------------------------------------------------- /cf_green_blue_deploy_suite_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | "github.com/onsi/ginkgo/reporters" 6 | . "github.com/onsi/gomega" 7 | 8 | "testing" 9 | ) 10 | 11 | func TestCfGreenBlueDeploy(t *testing.T) { 12 | RegisterFailHandler(Fail) 13 | junitReporter := reporters.NewJUnitReporter("junit.xml") 14 | RunSpecsWithDefaultAndCustomReporters(t, "CfGreenBlueDeploy Suite", []Reporter{junitReporter}) 15 | } 16 | -------------------------------------------------------------------------------- /manifest/manifest_suite_test.go: -------------------------------------------------------------------------------- 1 | package manifest_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | "github.com/onsi/ginkgo/reporters" 6 | . "github.com/onsi/gomega" 7 | 8 | "testing" 9 | ) 10 | 11 | func TestManifest(t *testing.T) { 12 | RegisterFailHandler(Fail) 13 | junitReporter := reporters.NewJUnitReporter("manifest-junit.xml") 14 | RunSpecsWithDefaultAndCustomReporters(t, "Manifest Suite", []Reporter{junitReporter}) 15 | 16 | } 17 | -------------------------------------------------------------------------------- /script/ci/concourse/ci_build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -x 3 | 4 | export GOPATH=$PWD 5 | export PLUGIN_VERSION=$(cat version/number) 6 | 7 | pushd src/github.com/bluemixgaragelondon/cf-blue-green-deploy 8 | . script/with_env 9 | ./script/build 10 | popd 11 | 12 | tar -C src/github.com/bluemixgaragelondon/cf-blue-green-deploy/artefacts -zcvf blue-green-deploy-"$PLUGIN_VERSION.tar.gz" . 13 | 14 | mv blue-green-deploy-"$PLUGIN_VERSION.tar.gz" artefacts 15 | 16 | -------------------------------------------------------------------------------- /manifest/fakes/fake_manifest_reader.go: -------------------------------------------------------------------------------- 1 | package fakes 2 | 3 | import ( 4 | "github.com/bluemixgaragelondon/cf-blue-green-deploy/manifest" 5 | "github.com/cloudfoundry-incubator/candiedyaml" 6 | ) 7 | 8 | type FakeManifestReader struct { 9 | Yaml string 10 | Err error 11 | } 12 | 13 | func (manifestReader *FakeManifestReader) Read() (*manifest.Manifest, error) { 14 | yamlMap := make(map[string]interface{}) 15 | candiedyaml.Unmarshal([]byte(manifestReader.Yaml), &yamlMap) 16 | 17 | if manifestReader.Err != nil { 18 | return nil, manifestReader.Err 19 | } else { 20 | return &manifest.Manifest{Data: yamlMap}, nil 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export CF_URL="api.eu-gb.bluemix.net" 4 | export CF_USERNAME="garage@uk.ibm.com" 5 | export CF_ORG="bluemix-garage-london" 6 | export CF_SPACE="${CF_SPACE:-cf-bgd}" 7 | 8 | export GOPATH="${GOPATH:-${WORKSPACE}}" 9 | export PATH="${GOPATH}/bin:${PATH}" 10 | 11 | export PLUGIN_NAME="blue-green-deploy" 12 | export PLUGIN_VERSION="${PLUGIN_VERSION:-`cat .version`}" 13 | 14 | export APP_PKG_NAME="github.com/bluemixgaragelondon/cf-blue-green-deploy" 15 | 16 | export TEST_ACCEPTANCE_LOG="/tmp/test_acceptance.log" 17 | export TEST_ACCEPTANCE_APP_NAME="${TEST_ACCEPTANCE_APP_NAME:=cf-blue-green-deploy-test-app}" 18 | export TEST_ACCEPTANCE_APP_HOSTNAME="${TEST_ACCEPTANCE_APP_HOSTNAME:=cf-bgd}" 19 | -------------------------------------------------------------------------------- /script/deps: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | [ -z "$DEBUG" ] || set -x 4 | 5 | CF_CLI_VERSION="v6.28.0" 6 | 7 | . script/common 8 | GO_PACKAGE_PATH=$(get_go_package_dir) 9 | 10 | main() { 11 | # go get fails because of generated deps 12 | go get -t || true 13 | # sort out those generated deps 14 | cf_cli_set_version 15 | cf_cli_generate_language_resources 16 | } 17 | 18 | cf_cli_set_version() { 19 | pushd "$GO_PACKAGE_PATH/src/code.cloudfoundry.org/cli" 20 | git fetch 21 | git checkout -f "$CF_CLI_VERSION" 22 | popd 23 | } 24 | 25 | cf_cli_generate_language_resources() { 26 | pushd "$GO_PACKAGE_PATH/src/code.cloudfoundry.org/cli" 27 | bin/generate-language-resources 28 | popd 29 | } 30 | 31 | main 32 | -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | . script/with_env 4 | 5 | PLUGIN_NAME="${PLUGIN_NAME:?Must be set in .env}" 6 | PLUGIN_VERSION="${PLUGIN_VERSION:?Must be set in .env}" 7 | 8 | script/deps 9 | script/test 10 | 11 | targets=( 12 | "osx darwin amd64" 13 | "linux32 linux 386" 14 | "linux64 linux amd64" 15 | "win32 windows 386" 16 | "win64 windows amd64" 17 | ) 18 | 19 | mkdir -p artefacts 20 | 21 | for target in "${targets[@]}" 22 | do 23 | read platform goos goarch <<< $target 24 | 25 | binary_name="${PLUGIN_NAME}.$platform" 26 | GOOS="$goos" GOARCH="$goarch" go build -ldflags "-X main.PluginVersion=${PLUGIN_VERSION}" -o "$binary_name" 27 | mv "$binary_name" artefacts 28 | done 29 | 30 | cp .env artefacts 31 | -------------------------------------------------------------------------------- /script/ci/bluemix-devops/ci_publish: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -x 3 | 4 | dir=`dirname $0` 5 | . $dir/ci_env 6 | 7 | git clone https://bluemixgaragelondon:$PLUGIN_REPO_TOKEN@git.ng.bluemix.net/bluemixgaragelondon/garage-cf-plugins 8 | git clone https://github.com/fsaintjacques/semver-tool 9 | pushd semver-tool 10 | git checkout tags/1.2.1 11 | popd 12 | 13 | git config --global user.name "BluemixGarage CI" 14 | git config --global user.email "garage+ci@uk.ibm.com" 15 | 16 | . .env 17 | 18 | cd garage-cf-plugins 19 | 20 | # Take care of version manipulation 21 | # Use the version in source control in github bgd, and add a build number 22 | ../semver-tool/src/semver bump --force ${PLUGIN_VERSION} 23 | ../semver-tool/src/semver bump build ${BUILD_NUMBER} 24 | # The semver tool writes its version to .version 25 | git add .version 26 | export PLUGIN_VERSION=$(cat .version) 27 | 28 | cd .. 29 | cp artefacts/* garage-cf-plugins/build 30 | cd garage-cf-plugins 31 | script/ci 32 | git push origin master -------------------------------------------------------------------------------- /script/ci/bluemix-devops/ci_acceptance: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -x 3 | 4 | dir=`dirname $0` 5 | . $dir/ci_env 6 | 7 | # Set CF_HOME or the scripts will set it to the wrong thing 8 | export CF_HOME=~ 9 | export API_KEY=$PIPELINE_BLUEMIX_API_KEY 10 | 11 | # Update the directory structure of the extract to be go-friendly 12 | mkdir -p go/src/github.com/bluemixgaragelondon/cf-blue-green-deploy 13 | 14 | shopt -s dotglob nullglob extglob 15 | mv !(go) go/src/github.com/bluemixgaragelondon/cf-blue-green-deploy 16 | cd go 17 | 18 | pushd src/github.com/bluemixgaragelondon/cf-blue-green-deploy 19 | . script/with_env 20 | popd 21 | 22 | pushd src/github.com/bluemixgaragelondon/cf-blue-green-deploy/artefacts 23 | cf install-plugin "${PLUGIN_NAME}.linux64" <<< "y" 24 | bx cf install-plugin "${PLUGIN_NAME}.linux64" <<< "y" 25 | popd 26 | 27 | 28 | pushd src/github.com/bluemixgaragelondon/cf-blue-green-deploy 29 | echo '--------- acceptance testing with cf --------------' 30 | ./script/test_acceptance 31 | popd 32 | -------------------------------------------------------------------------------- /script/with_env: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This will get sourced, important to be left in this format 4 | set -e 5 | 6 | ARGS=($@) 7 | 8 | main() { 9 | debug 10 | set_base_path 11 | set_env_files 12 | source_env_files 13 | set_cf_home 14 | ${ARGS[*]} 15 | } 16 | 17 | debug() { 18 | [ -z "$DEBUG" ] || set -x 19 | } 20 | 21 | set_base_path() { 22 | SOURCE="${BASH_SOURCE[0]}" 23 | DIR="$(dirname "$SOURCE")" 24 | 25 | while [ -h "$SOURCE" ] 26 | do 27 | SOURCE="$(readlink "$SOURCE")" 28 | [ "$SOURCE" != "/*" ] && SOURCE="$DIR/$SOURCE" 29 | DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" 30 | done 31 | 32 | BASE_PATH="$(cd "$DIR/.." && pwd)" 33 | } 34 | 35 | set_env_files() { 36 | BASE_PATH="${BASE_PATH:?must be defined}" 37 | 38 | ENV_FILE="$BASE_PATH/.env" 39 | SECRETS_FILE="$BASE_PATH/.secrets" 40 | } 41 | 42 | source_env_files() { 43 | ENV_FILE="${ENV_FILE:?must be defined}" 44 | SECRETS_FILE="${SECRETS_FILE:?must be defined}" 45 | 46 | local env_file 47 | for env_file in "$SECRETS_FILE" "$ENV_FILE" 48 | do 49 | if [ -f "$env_file" ] 50 | then 51 | . "$env_file" 52 | fi 53 | done 54 | 55 | } 56 | 57 | set_cf_home() { 58 | BASE_PATH="${BASE_PATH:?must be defined}" 59 | 60 | export CF_HOME="${CF_HOME:-$BASE_PATH}" 61 | } 62 | 63 | main 64 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | GINKGO_ARGS=($@) 4 | 5 | . script/common 6 | GO_PACKAGE_PATH=$(get_go_package_dir) 7 | 8 | main() { 9 | . script/with_env 10 | install_test_dependencies 11 | 12 | within_app run_tests 13 | 14 | within_app prepare_code_coverage_results 15 | } 16 | 17 | install_test_dependencies() { 18 | which ginkgo > /dev/null 2>&1|| go get github.com/onsi/ginkgo/ginkgo 19 | go tool | grep cover > /dev/null 2>&1 || go get golang.org/x/tools/cmd/cover 20 | } 21 | 22 | within_app() { 23 | pushd "${GO_PACKAGE_PATH}/src/${APP_PKG_NAME}" >/dev/null 24 | "$@" 25 | popd >/dev/null 26 | } 27 | 28 | run_tests() { 29 | ginkgo ${GINKGO_ARGS[*]} "$@" -r -randomizeAllSpecs 30 | 31 | rm -rf test-results/ 32 | mkdir -p test-results 33 | 34 | find . -path ./test-results -prune -o -type f -iname "*junit.xml" -exec mv {} test-results \; 35 | 36 | echo "Test reports are in the test-results folder." 37 | } 38 | 39 | prepare_code_coverage_results() { 40 | run_tests -cover > /dev/null 2>&1 41 | find . -type f -iname "*.coverprofile" | while read; do 42 | go tool cover -html="$REPLY" -o="$REPLY.html" 43 | mv "$REPLY.html" coverage 44 | rm "$REPLY" 45 | done 46 | 47 | echo "Test coverage details are in the coverage folder." 48 | 49 | 50 | } 51 | 52 | main 53 | -------------------------------------------------------------------------------- /args.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | ) 6 | 7 | type Args struct { 8 | SmokeTestPath string 9 | ManifestPath string 10 | AppName string 11 | DeleteOldApps bool 12 | } 13 | 14 | func NewArgs(osArgs []string) Args { 15 | args := Args{} 16 | args.AppName = extractAppName(osArgs) 17 | 18 | // Only use FlagSet so that we can pass string slice to Parse 19 | f := flag.NewFlagSet("blue-green-deploy", flag.ExitOnError) 20 | 21 | f.StringVar(&args.SmokeTestPath, "smoke-test", "", "") 22 | f.StringVar(&args.ManifestPath, "f", "", "") 23 | f.BoolVar(&args.DeleteOldApps, "delete-old-apps", false, "") 24 | 25 | f.Parse(extractBgdArgs(osArgs)) 26 | 27 | return args 28 | } 29 | 30 | func indexOfAppName(osArgs []string) int { 31 | index := 0 32 | for i, arg := range osArgs { 33 | if arg == "blue-green-deploy" || arg == "bgd" { 34 | index = i + 1 35 | break 36 | } 37 | } 38 | if len(osArgs) > index { 39 | return index 40 | } 41 | return -1 42 | } 43 | 44 | func extractAppName(osArgs []string) string { 45 | // Assume an app name will be passed - issue #27 46 | index := indexOfAppName(osArgs) 47 | if index >= 0 { 48 | return osArgs[index] 49 | } 50 | return "" 51 | } 52 | 53 | func extractBgdArgs(osArgs []string) []string { 54 | index := indexOfAppName(osArgs) 55 | if index >= 0 && len(osArgs) > index+1 { 56 | return osArgs[index+1:] 57 | } 58 | 59 | return []string{} 60 | } 61 | -------------------------------------------------------------------------------- /script/ci/bluemix-devops/pipeline.yml: -------------------------------------------------------------------------------- 1 | --- 2 | stages: 3 | - name: Build 4 | inputs: 5 | - url: https://github.com/bluemixgaragelondon/cf-blue-green-deploy.git 6 | type: git 7 | branch: master 8 | dir_name: null 9 | triggers: 10 | - type: commit 11 | jobs: 12 | - name: Build 13 | type: builder 14 | artifact_dir: go/src/github.com/bluemixgaragelondon/cf-blue-green-deploy 15 | build_type: shell 16 | script: | 17 | #!/bin/bash 18 | set -e -x 19 | 20 | script/ci/bluemix-devops/ci_build 21 | enable_tests: true 22 | test_file_pattern: go/src/github.com/bluemixgaragelondon/cf-blue-green-deploy/junit.xml 23 | - name: Acceptance Tests 24 | inputs: 25 | - type: job 26 | stage: Build 27 | job: Build 28 | dir_name: null 29 | triggers: 30 | - type: stage 31 | jobs: 32 | - name: Tests 33 | type: deployer 34 | target: 35 | region_id: ibm:yp:eu-gb 36 | organization: bluemix-garage-london 37 | space: cf-bgd 38 | application: Pipeline 39 | script: |- 40 | #!/bin/bash 41 | 42 | script/ci/bluemix-devops/ci_acceptance 43 | - name: Publish 44 | inputs: 45 | - type: job 46 | stage: Build 47 | job: Build 48 | dir_name: null 49 | triggers: 50 | - type: stage 51 | properties: 52 | - name: PLUGIN_REPO_PASSWORD 53 | type: secure 54 | - name: PLUGIN_REPO_USERID 55 | type: secure 56 | jobs: 57 | - name: Deploy to Garage Plugin Repo 58 | type: deployer 59 | target: 60 | region_id: ibm:yp:eu-gb 61 | organization: bluemix-garage-london 62 | space: ci 63 | application: Pipeline 64 | script: |- 65 | #!/bin/bash 66 | set -e -x 67 | 68 | script/ci/bluemix-devops/ci_publish 69 | hooks: 70 | - enabled: true 71 | label: null 72 | ssl_enabled: false 73 | url: https://devops-api.ng.bluemix.net/v1/messaging/webhook/publish 74 | -------------------------------------------------------------------------------- /script/ci/bluemix-devops/ci_build: -------------------------------------------------------------------------------- 1 | set -e -x 2 | 3 | dir=`dirname $0` 4 | . $dir/ci_env 5 | 6 | # Install Go 7 | 8 | GO_VERSION="1.9.4" 9 | 10 | DFILE="go$GO_VERSION.linux-amd64.tar.gz" 11 | 12 | if [ -d "$HOME/.go" ] || [ -d "$HOME/go" ]; then 13 | echo "Installation directories already exist. Exiting." 14 | exit 1 15 | fi 16 | echo "Downloading $DFILE ..." 17 | wget --quiet https://storage.googleapis.com/golang/$DFILE -O /tmp/go.tar.gz 18 | if [ $? -ne 0 ]; then 19 | echo "Download failed! Exiting." 20 | exit 1 21 | fi 22 | echo "Extracting ..." 23 | tar -C "$HOME" -xzf /tmp/go.tar.gz 24 | mv "$HOME/go" "$HOME/.go" 25 | touch "$HOME/.bashrc" 26 | { 27 | echo '# GoLang' 28 | echo 'export GOROOT=$HOME/.go' 29 | echo 'export PATH=$PATH:$GOROOT/bin' 30 | echo 'export GOPATH=$HOME/go' 31 | echo 'export PATH=$PATH:$GOPATH/bin' 32 | } >> "$HOME/.bashrc" 33 | 34 | mkdir -p "$HOME/go/{src,pkg,bin}" 35 | echo -e "\nGo $VERSION was installed.\nMake sure to relogin into your shell or run:" 36 | echo -e "\n\tsource $HOME/.bashrc\n\nto update your environment variables." 37 | echo "Tip: Opening a new terminal window usually just works. :)" 38 | rm -f /tmp/go.tar.gz 39 | 40 | source /home/pipeline/.bashrc 41 | 42 | # Update the directory structure of the extract to be go-friendly 43 | mkdir -p go/src/github.com/bluemixgaragelondon/cf-blue-green-deploy 44 | shopt -s dotglob nullglob extglob 45 | mv !(go) go/src/github.com/bluemixgaragelondon/cf-blue-green-deploy 46 | cd go 47 | 48 | go env 49 | 50 | # Run the build 51 | export GOPATH=$PWD 52 | 53 | go env 54 | 55 | pushd src/github.com/bluemixgaragelondon/cf-blue-green-deploy 56 | . script/with_env 57 | go get -t || true 58 | ./script/build 59 | 60 | # Then, publish results to DevOps Insights 61 | export PATH=/opt/IBM/node-v4.2/bin:$PATH 62 | npm install -g grunt-idra3 63 | idra --publishtestresult --filelocation="./test-results/*xml" --type=unittest 64 | 65 | # make a note of our git url 66 | git remote -v | head -1 | sed 's/origin//g' | sed 's/(fetch)//g' | sed -E "s/[[:space:]]+//g" > artefacts/.gitorigin 67 | git rev-parse HEAD > artefacts/.gitcommithash 68 | popd -------------------------------------------------------------------------------- /manifest/manifest_reader_test.go: -------------------------------------------------------------------------------- 1 | package manifest_test 2 | 3 | import ( 4 | "github.com/bluemixgaragelondon/cf-blue-green-deploy/manifest" 5 | . "github.com/onsi/ginkgo" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | var _ = Describe("Manifest reader", func() { 10 | 11 | Context("when a custom manifest file is provided", func() { 12 | 13 | It("should load that file rather than a default one", func() { 14 | reader := &manifest.FileManifestReader{ManifestPath: "../fixtures/custom-manifest.yml"} 15 | manifest, err := reader.Read() 16 | Expect(manifest).ToNot(BeNil()) 17 | Expect(manifest.Data["name"]).To(Equal("my-app")) 18 | Expect(err).To(BeNil()) 19 | }) 20 | }) 21 | 22 | Context("when a custom directory (but no file name)", func() { 23 | 24 | It("should load the default file from the custom directory", func() { 25 | reader := &manifest.FileManifestReader{ManifestPath: "../fixtures"} 26 | manifest, err := reader.Read() 27 | Expect(manifest).ToNot(BeNil()) 28 | Expect(manifest.Data["name"]).To(Equal("plain-app")) 29 | Expect(err).To(BeNil()) 30 | }) 31 | }) 32 | 33 | Context("When no manifest file is present", func() { 34 | 35 | It("Returns nil with error message", func() { 36 | reader := &manifest.FileManifestReader{ManifestPath: "../doesnotexist"} 37 | manifest, err := reader.Read() 38 | Expect(manifest).To(BeNil()) 39 | Expect(err).ToNot(BeNil()) 40 | }) 41 | }) 42 | 43 | Context("When manifest file is empty", func() { 44 | 45 | It("Returns nil with error message", func() { 46 | reader := &manifest.FileManifestReader{ManifestPath: "../fixtures/emptymanifest.yml"} 47 | manifest, err := reader.Read() 48 | Expect(manifest).To(BeNil()) 49 | Expect(err).ToNot(BeNil()) 50 | }) 51 | }) 52 | 53 | Context("When nothing is passed", func() { 54 | 55 | It("Returns nil with an error", func() { 56 | reader := &manifest.FileManifestReader{} 57 | manifest, err := reader.Read() 58 | Expect(manifest).To(BeNil()) 59 | Expect(err).ToNot(BeNil()) 60 | }) 61 | }) 62 | 63 | Context("When a manifest which inherits config from another manifest is passed", func() { 64 | 65 | It("Returns the configurations from the passed in manifest and all inherited manifests", func() { 66 | reader := &manifest.FileManifestReader{ManifestPath: "../fixtures/manifestwithinheritance.yml"} 67 | manifest, err := reader.Read() 68 | Expect(err).To(BeNil()) 69 | Expect(manifest).ToNot(BeNil()) 70 | Expect(manifest.Data["name"]).To(Equal("fancy-app")) 71 | Expect(manifest.Data["domain"]).To(Equal("shared-domain.example.com")) 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /script/common: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # On IBM Cloud, some commands are of the form 'bx [stuff]', and others are 'bx cf [stuff]' 4 | cf_platform_command=${1:-cf} 5 | cf_command="${1:-} cf" 6 | 7 | login_to_bluemix() { 8 | CF_URL="${CF_URL:?must be defined}" 9 | CF_PASSWORD="${CF_PASSWORD:=$CF_TOKEN}" 10 | 11 | CF_ORG="${CF_ORG:?must be defined}" 12 | CF_SPACE="${CF_SPACE:?must be defined}" 13 | 14 | set -o pipefail 15 | if ! $cf_command apps | grep -e "org ${CF_ORG}.*space ${CF_SPACE}.*as ${CF_USERNAME}" >/dev/null 16 | then 17 | if [ -n "${API_KEY+1}" ] 18 | then 19 | if [ "${cf_platform_command+1}"=='cf' ]; then 20 | $cf_platform_command login -a "$CF_URL" -u apikey -p "$API_KEY" -o "$CF_ORG" -s "$CF_SPACE" || $cf_command create-space "$CF_SPACE" 21 | else 22 | $cf_platform_command login -a "$CF_URL" --apikey "$API_KEY" -o "$CF_ORG" -s "$CF_SPACE" || $cf_command create-space "$CF_SPACE" 23 | fi 24 | else 25 | CF_PASSWORD="${CF_PASSWORD:?must be defined since API_KEY is not set}" 26 | CF_USERNAME="${CF_USERNAME:?must be defined}" 27 | $cf_platform_command login -a "$CF_URL" -u "$CF_USERNAME" -p "$CF_PASSWORD" -o "$CF_ORG" -s "$CF_SPACE" || $cf_command create-space "$CF_SPACE" 28 | fi 29 | $cf_platform_command target -s "$CF_SPACE" 30 | fi 31 | set +o pipefail 32 | } 33 | 34 | uninstall_plugin() { 35 | local plugin_name="$1" 36 | 37 | plugin_not_installed? "$plugin_name" || $cf_platform_command uninstall-plugin "$plugin_name" 38 | } 39 | 40 | plugin_not_installed?() { 41 | local plugin_name="$1" 42 | 43 | ! grep "$plugin_name" <<< "$($cf_platform_command plugins)" >&- 44 | } 45 | 46 | install_plugin() { 47 | local plugin_name="$1" 48 | 49 | $cf_platform_command install-plugin -f "artefacts/${plugin_name}.$(platform_name)" 50 | } 51 | 52 | push_example_apps() { 53 | pushd acceptance/app 54 | login_to_bluemix 55 | local app_name="$1" 56 | local app_host_name="$2" 57 | $cf_command push "${app_name}-old" 58 | $cf_command push "$app_name" 59 | $cf_command map-route "$app_name" eu-gb.mybluemix.net -n "$app_host_name" 60 | popd 61 | } 62 | 63 | platform_name() { 64 | platform=$(go version | awk '{print $4}' | sed 's|/| |') 65 | case $platform in 66 | "darwin amd64") 67 | echo "osx" 68 | ;; 69 | "linux 386") 70 | echo "linux32" 71 | ;; 72 | "linux amd64") 73 | echo "linux64" 74 | ;; 75 | "windows 386") 76 | echo "win32" 77 | ;; 78 | "windows amd64") 79 | echo "win64" 80 | ;; 81 | esac 82 | } 83 | 84 | get_go_package_dir() { 85 | echo "$GOPATH" | cut -d : -f 1 86 | } 87 | -------------------------------------------------------------------------------- /script/ci/concourse/pipeline.yml: -------------------------------------------------------------------------------- 1 | --- 2 | jobs: 3 | - name: build 4 | serial_groups: [version] 5 | plan: 6 | - get: midday-timer 7 | trigger: true 8 | - get: bgd-git 9 | trigger: true 10 | - get: version 11 | params: {bump: patch} 12 | - task: build and test 13 | file: bgd-git/script/ci/concourse/build.yml 14 | - put: bgd-artefact-swift 15 | params: {from: artefacts/.*\.tar\.gz} 16 | - put: version 17 | params: {file: version/number} 18 | - name: acceptance 19 | plan: 20 | - get: bgd-git 21 | - get: bgd-artefact-swift 22 | trigger: true 23 | passed: [build] 24 | - task: acceptance 25 | file: bgd-git/script/ci/concourse/acceptance.yml 26 | params: 27 | CF_PASSWORD: {{cf-password}} 28 | - name: build-plugin-repo 29 | plan: 30 | - get: garage-cf-plugins-git 31 | - get: bgd-artefact-swift 32 | passed: 33 | - acceptance 34 | trigger: true 35 | - task: build-plugin-repo 36 | file: garage-cf-plugins-git/script/ci/concourse/build.yml 37 | - put: garage-cf-plugins-git 38 | params: 39 | repository: updated-garage-cf-plugins-git 40 | 41 | resources: 42 | - name: bgd-git 43 | type: git 44 | source: 45 | uri: https://github.com/bluemixgaragelondon/cf-blue-green-deploy 46 | branch: master 47 | 48 | - name: garage-cf-plugins-git 49 | type: git 50 | source: 51 | branch: master 52 | uri: {{garage-cf-plugin-repo}} 53 | git_config: 54 | - name: user.name 55 | value: BluemixGarage CI 56 | - name: user.email 57 | value: garage+ci@uk.ibm.com 58 | 59 | - name: midday-timer 60 | type: time 61 | source: 62 | start: 12:00 +0000 63 | stop: 13:00 +0000 64 | days: 65 | - Monday 66 | - Tuesday 67 | - Wednesday 68 | - Thursday 69 | - Friday 70 | 71 | - name: version 72 | type: semver 73 | source: 74 | initial_version: "0.0.0" #Bug in the semver-resource, this is required. 75 | driver: swift 76 | openstack: 77 | container: bgd-version 78 | item_name: version 79 | region: dallas 80 | identity_endpoint: https://lon-identity.open.softlayer.com/v3 81 | domain_name: "884793" 82 | username: {{swift-username}} 83 | password: {{swift-password}} 84 | 85 | - name: bgd-artefact-swift 86 | type: swift 87 | source: 88 | username: {{swift-username}} 89 | api_key: {{swift-password}} 90 | auth_url: https://lon-identity.open.softlayer.com/v3 91 | domain: "884793" 92 | container: bgd-artefacts 93 | regex: blue-green-deploy-([.0-9]+)\.tar\.gz 94 | region: #Resource does not support regions, defaults to dallas 95 | 96 | resource_types: 97 | - name: swift 98 | type: docker-image 99 | source: 100 | repository: databus23/concourse-swift-resource 101 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Blue-Green-Deploy plugin 2 | 3 | We welcome contributions but request that you follow these guidelines. 4 | 5 | ## Reporting a bug 6 | 7 | Please raise any bug reports on the issue tracker. 8 | Be sure to search the list to see if your issue has already been raised. 9 | 10 | A good bug report is one that make it easy for us to understand what you were trying to do and what went wrong. Also, provide as much context as possible so we can try to recreate the issue. 11 | 12 | ## New features 13 | 14 | Please raise any new feature requests on the issue tracker. 15 | 16 | ## Pull requests 17 | 18 | ### Changes to existing code 19 | 20 | If you want to raise a pull request with a new feature, a bug fix, or a refactoring of existing code, make sure to open an issue in the project's issue tracker first before engaging in serious development work. This will start a discussion and help in accepting the pull request. 21 | 22 | ### Contributor License Agreement 23 | 24 | In order for us to accept pull requests, you must declare that you wrote the code or, at least, have the right to contribute it to the repo under the open source licence of the project in the repo. It's dead easy... 25 | 26 | 1. Read this (from [developercertificate.org](http://developercertificate.org/)): 27 | 28 | ``` 29 | Developer Certificate of Origin 30 | Version 1.1 31 | 32 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 33 | 660 York Street, Suite 102, 34 | San Francisco, CA 94110 USA 35 | 36 | Everyone is permitted to copy and distribute verbatim copies of this 37 | license document, but changing it is not allowed. 38 | 39 | 40 | Developer's Certificate of Origin 1.1 41 | 42 | By making a contribution to this project, I certify that: 43 | 44 | (a) The contribution was created in whole or in part by me and I 45 | have the right to submit it under the open source license 46 | indicated in the file; or 47 | 48 | (b) The contribution is based upon previous work that, to the best 49 | of my knowledge, is covered under an appropriate open source 50 | license and I have the right under that license to submit that 51 | work with modifications, whether created in whole or in part 52 | by me, under the same open source license (unless I am 53 | permitted to submit under a different license), as indicated 54 | in the file; or 55 | 56 | (c) The contribution was provided directly to me by some other 57 | person who certified (a), (b) or (c) and I have not modified 58 | it. 59 | 60 | (d) I understand and agree that this project and the contribution 61 | are public and that a record of the contribution (including all 62 | personal information I submit with it, including my sign-off) is 63 | maintained indefinitely and may be redistributed consistent with 64 | this project or the open source license(s) involved. 65 | ``` 66 | 67 | 2. If you can certify that it is true, sign off your `git commit` with a message like this: 68 | ``` 69 | DCO 1.1 Signed-off-by: Rob Smith 70 | ``` 71 | You must use your real name (no pseudonyms or anonymous contributions, sorry). 72 | 73 | Instead of typing that in every git commit message, your Git tools might let you automatically add the details for you. If you configure them to do that, when you issue the `git commit` command, just add the `-s` option. 74 | 75 | If you are an IBMer, please contact us directly as the contribution process is 76 | slightly different. 77 | 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blue/Green deployer plugin for CF 2 | 3 | ## Introduction 4 | 5 | **cf-blue-green-deploy** is a plugin for the CF command line tool that 6 | automates a few steps involved in zero-downtime deploys. 7 | 8 | ## Overview 9 | 10 | The plugin takes care of the following steps packaged into one command: 11 | 12 | * Pushes the current version of the app with a new name 13 | * Optionally runs smoke tests against the newly pushed app to verify the deployment 14 | * If smoke tests fail, newly pushed app gets marked as failed and left around for investigation 15 | * If smoke tests pass, remaps routes from the currently live app to the newly deployed app 16 | * Cleans up versions of the app no longer in use 17 | 18 | ## How to use 19 | 20 | * Get the plugin from the CF Community Repository 21 | 22 | ``` 23 | cf add-plugin-repo CF-Community https://plugins.cloudfoundry.org 24 | cf install-plugin blue-green-deploy -r CF-Community 25 | ``` 26 | 27 | In scripts, add the `-f` flag to `install-plugin` for non-interactive mode. 28 | 29 | * Deploy your app 30 | 31 | ``` 32 | cd your_app_root 33 | cf blue-green-deploy app_name 34 | ``` 35 | 36 | * Deploy with optional smoke tests 37 | 38 | ``` 39 | cf blue-green-deploy app_name --smoke-test 40 | ``` 41 | 42 | * Deploy with specific manifest file 43 | 44 | ``` 45 | cf blue-green-deploy app_name -f 46 | ``` 47 | 48 | * Deploy with a hard clean-up of the 'blue' (original) app 49 | 50 | ``` 51 | cf blue-green-deploy app_name --delete-old-apps 52 | ``` 53 | 54 | * You can also use the shorter alias 55 | 56 | ``` 57 | cf bgd app_name 58 | ``` 59 | 60 | The only argument passed to the smoke test script is the FQDN of the newly 61 | pushed app. If the smoke test returns with a non-zero exit code the deploy 62 | process will stop and fail, the current live app will not be affected. 63 | 64 | If the test script exits with a zero exit code, the plugin will remap all 65 | routes from the current live app to the new app. The plugin supports routes 66 | under custom domains. 67 | 68 | ## How to build 69 | 70 | Before cloning the source, you may wish to set up GOPATH and a go-friendly folder hierarchy to avoid path issues. Run the following in your preferred working directory: 71 | 72 | ``` 73 | mkdir ./go 74 | export GOPATH=`pwd`/go 75 | mkdir -p go/src/github.com/bluemixgaragelondon/ 76 | cd go/src/github.com/bluemixgaragelondon/ 77 | git clone https://github.com/bluemixgaragelondon/cf-blue-green-deploy 78 | cd cf-blue-green-deploy 79 | ``` 80 | 81 | Then run a build: 82 | 83 | ``` 84 | script/build 85 | ``` 86 | 87 | This will download dependencies, run the tests, and build binaries in the 88 | _artefacts_ folder. 89 | 90 | ## How to run tests 91 | 92 | ``` 93 | script/test 94 | ``` 95 | 96 | This will run the unit tests. To run the acceptance tests (which need a Cloud Foundry instance), use 97 | 98 | ``` 99 | script/test_acceptance 100 | ``` 101 | 102 | You almost certainly want to install the plugin before running the acceptance tests (to make sure the latest version of the plugin is being tested). On OS X, the command would be 103 | 104 | ``` 105 | script/build ; script/install ; script/test_acceptance 106 | ``` 107 | 108 | See [instructions for releasing a project](https://github.com/bluemixgaragelondon/cf-blue-green-deploy/blob/master/release.md) 109 | for instructions on how to setup the acceptance tests. 110 | 111 | ``` 112 | 113 | ``` 114 | -------------------------------------------------------------------------------- /script/test_acceptance: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | 4 | trap cleanup EXIT 5 | 6 | # On IBM Cloud, some commands are of the form 'bx [stuff]', and others are 'bx cf [stuff]'. All the ones we need in this script are 'bx cf' 7 | cf_command="${1:-} cf" 8 | 9 | # The main function will have its own arguments (and $1 will refer to them), so save this argument 10 | command_argument=$1 11 | 12 | cleanup() { 13 | TEST_ACCEPTANCE_LOG="${TEST_ACCEPTANCE_LOG:?Must be defined in .env}" 14 | 15 | rm -f "$TEST_ACCEPTANCE_LOG" 16 | } 17 | 18 | main() { 19 | . script/with_env 20 | . script/common $command_argument 21 | assert_plugin_is_installed 22 | 23 | if [ -z "$TURBO" ] 24 | then 25 | TEST_ACCEPTANCE_APP_HOSTNAME="${TEST_ACCEPTANCE_APP_HOSTNAME:?Must be defined in .env}" 26 | TEST_ACCEPTANCE_APP_NAME="${TEST_ACCEPTANCE_APP_NAME:=$TEST_ACCEPTANCE_APP_HOSTNAME}" 27 | push_example_apps "$TEST_ACCEPTANCE_APP_NAME" "$TEST_ACCEPTANCE_APP_HOSTNAME" 28 | fi 29 | 30 | 31 | pushd acceptance/app >/dev/null 32 | assert_plugin_output_includes_successful_smoke_test_output 33 | ignore_any_failures 34 | assert_plugin_fails_if_smoke_test_script_fails 35 | popd >/dev/null 36 | 37 | pushd acceptance/app-with-strangely-named-manifest >/dev/null 38 | assert_plugin_runs_cleanly_if_manifest_is_specified 39 | ignore_any_failures 40 | popd >/dev/null 41 | 42 | printf "\nACCEPTANCE TESTS PASSED!\n" 43 | } 44 | 45 | ignore_any_failures() { 46 | set +e 47 | } 48 | 49 | assert_plugin_is_installed() { 50 | $cf_command plugins | grep -q bgd && echo "Plugin is installed." || (echo "Plugin is not installed. Ending test."; exit 1) 51 | } 52 | 53 | assert_plugin_output_includes_successful_smoke_test_output() { 54 | TEST_ACCEPTANCE_LOG="${TEST_ACCEPTANCE_LOG:?Must be defined in .env}" 55 | 56 | local smoke_test_script="script/smoke_test" 57 | local smoke_test_output="Hello world from my Go program!" 58 | 59 | $cf_command bgd "$TEST_ACCEPTANCE_APP_NAME" --smoke-test "$smoke_test_script" | tee "$TEST_ACCEPTANCE_LOG" 60 | 61 | if ! grep "$smoke_test_output" "$TEST_ACCEPTANCE_LOG" 62 | then 63 | printf "\n\nExpected $cf_command bgd to include '%s' from %s output\n" "$smoke_test_output" "$smoke_test_script" 64 | exit 1 65 | fi 66 | } 67 | 68 | assert_plugin_fails_if_smoke_test_script_fails() { 69 | TEST_ACCEPTANCE_LOG="${TEST_ACCEPTANCE_LOG:?Must be defined in .env}" 70 | 71 | local smoke_test_script="script/smoke_test" 72 | local expected_output_last_line="Smoke tests failed" 73 | 74 | set -o pipefail 75 | $cf_command bgd "${TEST_ACCEPTANCE_APP_NAME}-FORCE-SMOKE-TEST-FAILURE" --smoke-test $smoke_test_script 2>&1 | tee "$TEST_ACCEPTANCE_LOG" 76 | local cf_bgd_exit_code=$? 77 | 78 | if [ $cf_bgd_exit_code != 1 ] 79 | then 80 | printf "\n\nExpected $cf_command bgd to exit with exit code 1, it exited with %s" $cf_bgd_exit_code 81 | exit 1 82 | fi 83 | 84 | if [ "$(tail -n 1 "$TEST_ACCEPTANCE_LOG")" != "$expected_output_last_line" ] 85 | then 86 | printf "\n\nExpected $cf_command bgd to stop with %s\n" "$expected_output_last_line" 87 | exit 1 88 | fi 89 | } 90 | 91 | assert_plugin_runs_cleanly_if_manifest_is_specified() { 92 | TEST_ACCEPTANCE_LOG="${TEST_ACCEPTANCE_LOG:?Must be defined in .env}" 93 | 94 | $cf_command bgd "$TEST_ACCEPTANCE_APP_NAME" -f strangely-named-manifest.yml | tee "$TEST_ACCEPTANCE_LOG" 95 | 96 | if [ $? -ne 0 ] 97 | then 98 | printf "\n\nExpected return code to be 0." 99 | exit 1 100 | fi 101 | } 102 | 103 | 104 | main 105 | -------------------------------------------------------------------------------- /release.md: -------------------------------------------------------------------------------- 1 | # Making a release 2 | 3 | Before making a public release, we should test for a couple of days by using the version of the plugin in 4 | the [garage plugin repo](https://garage-cf-plugins.eu-gb.mybluemix.net/list). 5 | All passing builds will be pushed to the staging repo automatically by the [IBM Cloud DevOps Pipeline](https://console.bluemix.net/devops/pipelines/4e5bb6ac-762d-42aa-abe1-71beabeafbb1?env_id=ibm:yp:us-south). 6 | 7 | 1. Update the markdown description in `.releaseDescription` to reflect the release contents. 8 | 9 | 1. If this is more (or less) than an minor release, update the semantic version in `.version`. 10 | 11 | 1. Check the output of the [latest build](https://console.ng.bluemix.net/devops/pipelines/4e5bb6ac-762d-42aa-abe1-71beabeafbb1) is green. 12 | 13 | 1. Manually run the 'Git release' build stage. Under the covers, that will do the following: 14 | 15 | 11. Tag a new revision using [semver](http://semver.org): `git tag vX.X.X` 16 | 17 | 11. Create [a new github release](https://github.com/bluemixgaragelondon/cf-blue-green-deploy/releases/new) and upload the binaries 18 | 19 | 11. Bump the `PLUGIN_VERSION` variable in `.version` to the next minor increment, ready for the next release 20 | 21 | 1. Follow the [instructions for submitting a plugin](https://github.com/cloudfoundry-incubator/cli-plugin-repo#submitting-plugins) 22 | You need to update the following in `repo-index.yml` under `cf-blue-green-deploy`. Use the output from the build job, which will include: 23 | 24 | * version 25 | * updated timestamp 26 | * url - this should be `https://github.com/bluemixgaragelondon/new_plugin/releases/download/vX.X.X/blue-green-deploy.PLATFORM` 27 | * sum - copied from [the garage staging repo](https://garage-cf-plugins.eu-gb.mybluemix.net/list) as this version will have passed all of the testing. 28 | 29 | # Running the acceptance tests 30 | 31 | You can run the acceptance tests on any cloud foundry installation by following these steps: 32 | 33 | 1. Edit `.env`: 34 | 35 | * Update the `CF_URL="api.eu-gb.bluemix.net"` to match your cloud foundry api url. 36 | 37 | * replace the values of `CF_USERNAME` and `CF_ORG` with your username and organization name (for a personal bluemix account this is typically your email address). 38 | 39 | * set the value of `CF_SPACE` to the name of a space in your org where the test should run. If it does not exist it will be created. 40 | 41 | * set the value of `TEST_ACCEPTANCE_APP_NAME` and `TEST_ACCEPTANCE_APP_HOSTNAME` to any unique values that are valid for the test app domain (eg. eu-gb.mybluemix.net). 42 | 43 | 1. Source `.env` to your shell. 44 | 45 | 1. Edit `acceptance/app/manifest.yml`. It governs the example app that is pushed during the acceptance test. 46 | 47 | * Either remove the `hosts:` section, or provide at least one unique hostname. 48 | 49 | * Provide at least one domain. In the `domains:` section, use any domain that is available to your cloud foundry org/space, eg. `eu-gb.mybluemix.net`. 50 | 51 | * The remaining fields can be left unchanged. 52 | 53 | 1. Set the `CF_PASSWORD` variable in your shell. On an interactive shell, run `read -s CF_PASSWORD` and type in your password followed by return. Avoid using `export` with this field, as any sub-shell could then read your password. 54 | 55 | 1. To install a locally built plugin and then run the acceptance tests: `script/build ; script/install ; CF_PASSWORD=$CF_PASSWORD script/test_acceptance`. 56 | 57 | 1. If the tests passed, there should be a message similar to `ACCEPTANCE TESTS PASSED!` printed when the test has finished. The exit value is 0 for a successful test. 58 | -------------------------------------------------------------------------------- /manifest/merge_reduce_test.go: -------------------------------------------------------------------------------- 1 | package manifest_test 2 | 3 | import ( 4 | "github.com/bluemixgaragelondon/cf-blue-green-deploy/manifest" 5 | . "github.com/onsi/ginkgo" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | var _ = Describe("Map converter and merger", func() { 10 | 11 | Context("When the input is not a map", func() { 12 | newMap, err := manifest.Mappify("hello") 13 | 14 | It("Returns nil", func() { 15 | Expect(newMap).To(BeNil()) 16 | }) 17 | 18 | It("Returns an error", func() { 19 | Expect(err).ToNot(BeNil()) 20 | }) 21 | It("Returns a descriptive error", func() { 22 | Expect(err.Error()).To(ContainSubstring("expected map")) 23 | }) 24 | 25 | }) 26 | 27 | Context("When converting an typed map to one with string keys", func() { 28 | testmap := make(map[string]string) 29 | mappedmap, err := manifest.Mappify(testmap) 30 | Context("when input map is empty", func() { 31 | It("Returns a map", func() { 32 | Expect(mappedmap).ToNot(BeNil()) 33 | }) 34 | 35 | It("Has no error", func() { 36 | Expect(err).To(BeNil()) 37 | }) 38 | }) 39 | 40 | Context("when input map is not empty", func() { 41 | testmap := make(map[interface{}]interface{}) 42 | testmap["foo"] = "bar" 43 | mappedmap, err := manifest.Mappify(testmap) 44 | It("Correctly handles keys", func() { 45 | Expect(mappedmap["foo"]).To(Equal("bar")) 46 | }) 47 | 48 | It("Has no error", func() { 49 | Expect(err).To(BeNil()) 50 | }) 51 | }) 52 | 53 | }) 54 | 55 | Context("When converting an untyped map to one with string keys", func() { 56 | testmap := make(map[interface{}]interface{}) 57 | mappedmap, err := manifest.Mappify(testmap) 58 | Context("when input map is empty", func() { 59 | It("Returns a map", func() { 60 | Expect(mappedmap).ToNot(BeNil()) 61 | }) 62 | 63 | It("Has no error", func() { 64 | Expect(err).To(BeNil()) 65 | }) 66 | }) 67 | 68 | Context("when input map is not empty", func() { 69 | testmap := make(map[interface{}]interface{}) 70 | testmap["foo"] = "bar" 71 | mappedmap, err := manifest.Mappify(testmap) 72 | It("Correctly converts keys which are actually strings", func() { 73 | Expect(mappedmap["foo"]).To(Equal("bar")) 74 | }) 75 | 76 | It("Has no error", func() { 77 | Expect(err).To(BeNil()) 78 | }) 79 | }) 80 | 81 | }) 82 | 83 | Context("When doing a deep merge of a map", func() { 84 | testmap1 := make(map[string]interface{}) 85 | testmap2 := make(map[string]interface{}) 86 | Context("when both input maps are empty", func() { 87 | mappedmap, err := manifest.DeepMerge(testmap1, testmap2) 88 | It("Returns a an empty map", func() { 89 | Expect(len(mappedmap)).To(Equal(0)) 90 | }) 91 | 92 | It("Has no error", func() { 93 | Expect(err).To(BeNil()) 94 | }) 95 | }) 96 | 97 | Context("when both input maps share a key", func() { 98 | testmap1["foo"] = "baz" 99 | testmap2["foo"] = "bar" 100 | 101 | mappedmap1, err := manifest.DeepMerge(testmap1, testmap2) 102 | It("Favours the value from the second argument", func() { 103 | Expect(mappedmap1["foo"]).To(Equal("bar")) 104 | }) 105 | 106 | It("Has no error", func() { 107 | Expect(err).To(BeNil()) 108 | }) 109 | 110 | mappedmap2, err := manifest.DeepMerge(testmap2, testmap1) 111 | It("Favours the value from the second argument if the arguments are reversed", func() { 112 | Expect(mappedmap2["foo"]).To(Equal("baz")) 113 | }) 114 | 115 | It("Has no error", func() { 116 | Expect(err).To(BeNil()) 117 | }) 118 | }) 119 | }) 120 | 121 | }) 122 | -------------------------------------------------------------------------------- /args_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | . "github.com/bluemixgaragelondon/cf-blue-green-deploy" 5 | . "github.com/onsi/ginkgo" 6 | . "github.com/onsi/gomega" 7 | "strings" 8 | ) 9 | 10 | var _ = Describe("Args", func() { 11 | Context("With an appname only", func() { 12 | args := NewArgs(bgdArgs("appname")) 13 | 14 | It("sets the app name", func() { 15 | Expect(args.AppName).To(Equal("appname")) 16 | }) 17 | 18 | It("does not set the smoke test file", func() { 19 | Expect(args.SmokeTestPath).To(BeZero()) 20 | }) 21 | 22 | It("does not set a manifest", func() { 23 | Expect(args.ManifestPath).To(BeZero()) 24 | }) 25 | 26 | It("does not delete old app instances", func() { 27 | Expect(args.DeleteOldApps).To(BeFalse()) 28 | }) 29 | }) 30 | 31 | Context("With a smoke test and an appname", func() { 32 | args := NewArgs(bgdArgs("appname --smoke-test script/smoke-test")) 33 | 34 | It("sets the smoke test file", func() { 35 | Expect(args.SmokeTestPath).To(Equal("script/smoke-test")) 36 | }) 37 | 38 | It("sets the app name", func() { 39 | Expect(args.AppName).To(Equal("appname")) 40 | }) 41 | 42 | It("does not set a manifest", func() { 43 | Expect(args.ManifestPath).To(BeZero()) 44 | }) 45 | 46 | It("does not delete old app instances", func() { 47 | Expect(args.DeleteOldApps).To(BeFalse()) 48 | }) 49 | }) 50 | 51 | Context("With an appname smoke test and a manifest", func() { 52 | args := NewArgs(bgdArgs("appname --smoke-test smokey -f custommanifest.yml")) 53 | 54 | It("sets the smoke test file", func() { 55 | Expect(args.SmokeTestPath).To(Equal("smokey")) 56 | }) 57 | 58 | It("sets the app name", func() { 59 | Expect(args.AppName).To(Equal("appname")) 60 | }) 61 | 62 | It("sets a manifest", func() { 63 | Expect(args.ManifestPath).To(Equal("custommanifest.yml")) 64 | }) 65 | 66 | It("does not delete old app instances", func() { 67 | Expect(args.DeleteOldApps).To(BeFalse()) 68 | }) 69 | }) 70 | 71 | Context("With an appname and a manifest", func() { 72 | args := NewArgs(bgdArgs("appname -f custommanifest.yml")) 73 | 74 | It("sets the app name", func() { 75 | Expect(args.AppName).To(Equal("appname")) 76 | }) 77 | 78 | It("sets a manifest", func() { 79 | Expect(args.ManifestPath).To(Equal("custommanifest.yml")) 80 | }) 81 | 82 | It("does not delete old app instances", func() { 83 | Expect(args.DeleteOldApps).To(BeFalse()) 84 | }) 85 | }) 86 | 87 | Context("When a global cf flag is set with an app name", func() { 88 | args := NewArgs([]string{"cf", "-v", "blue-green-deploy", "app"}) 89 | 90 | It("sets the app name", func() { 91 | Expect(args.AppName).To(Equal("app")) 92 | }) 93 | }) 94 | 95 | Context("When the bgd abbreviation is used", func() { 96 | args := NewArgs([]string{"cf", "bgd", "app"}) 97 | 98 | It("sets the app name", func() { 99 | Expect(args.AppName).To(Equal("app")) 100 | }) 101 | }) 102 | 103 | Context("With an appname and a manifest and the delete-old-apps flag", func() { 104 | args := NewArgs(bgdArgs("appname -f custommanifest.yml --delete-old-apps")) 105 | 106 | It("sets the app name", func() { 107 | Expect(args.AppName).To(Equal("appname")) 108 | }) 109 | 110 | It("sets a manifest", func() { 111 | Expect(args.ManifestPath).To(Equal("custommanifest.yml")) 112 | }) 113 | 114 | It("deletes old app instances", func() { 115 | Expect(args.DeleteOldApps).To(BeTrue()) 116 | }) 117 | }) 118 | }) 119 | 120 | func bgdArgs(argString string) []string { 121 | args := strings.Split(argString, " ") 122 | return append([]string{"blue-green-deploy"}, args...) 123 | } 124 | -------------------------------------------------------------------------------- /manifest/manifest_reader.go: -------------------------------------------------------------------------------- 1 | // NOTICE: This is a derivative work of https://github.com/cloudfoundry/cli/blob/master/cf/manifest/manifest_disk_repository.go. 2 | package manifest 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | 11 | "gopkg.in/yaml.v2" 12 | ) 13 | 14 | type ManifestReader interface { 15 | Read() (*Manifest, error) 16 | } 17 | 18 | type FileManifestReader struct { 19 | ManifestPath string 20 | } 21 | 22 | func (manifestReader FileManifestReader) Read() (*Manifest, error) { 23 | var manifest *Manifest 24 | var err error 25 | if path := manifestReader.ManifestPath; path == "" { 26 | manifest, err = manifestReader.readManifest("./") 27 | } else { 28 | manifest, err = manifestReader.readManifest(path) 29 | } 30 | 31 | if err != nil { 32 | return nil, err 33 | } 34 | return manifest, nil 35 | } 36 | 37 | func (manifestReader *FileManifestReader) readManifest(inputPath string) (*Manifest, error) { 38 | 39 | m := &Manifest{} 40 | manifestPath, err := manifestReader.interpetManifestPath(inputPath) 41 | 42 | if err != nil { 43 | return m, fmt.Errorf("Error finding manifest: %v", err) 44 | } 45 | 46 | m.Path = manifestPath 47 | 48 | mapp, err := manifestReader.readAllYAMLFiles(manifestPath) 49 | 50 | if err != nil { 51 | return m, err 52 | } 53 | 54 | m.Data = mapp 55 | 56 | return m, nil 57 | } 58 | 59 | func (manifestReader *FileManifestReader) readAllYAMLFiles(path string) (mergedMap map[string]interface{}, err error) { 60 | file, err := os.Open(filepath.Clean(path)) 61 | 62 | if err != nil { 63 | return 64 | } 65 | defer file.Close() 66 | 67 | mapp, err := parseManifest(file) 68 | if err != nil { 69 | return 70 | } 71 | 72 | if _, ok := mapp["inherit"]; !ok { 73 | mergedMap = mapp 74 | return 75 | } 76 | 77 | inheritedPath, ok := mapp["inherit"].(string) 78 | if !ok { 79 | err = fmt.Errorf("invalid inherit path in manifest") 80 | return 81 | } 82 | 83 | if !filepath.IsAbs(inheritedPath) { 84 | inheritedPath = filepath.Join(filepath.Dir(path), inheritedPath) 85 | } 86 | 87 | inheritedMap, err := manifestReader.readAllYAMLFiles(inheritedPath) 88 | if err != nil { 89 | return 90 | } 91 | 92 | mergedMap, err = DeepMerge(inheritedMap, mapp) 93 | if err != nil { 94 | return 95 | } 96 | return 97 | } 98 | 99 | func parseManifest(file io.Reader) (yamlMap map[string]interface{}, err error) { 100 | manifest, err := ioutil.ReadAll(file) 101 | if err != nil { 102 | return 103 | } 104 | 105 | yamlMap = make(map[string]interface{}) 106 | err = yaml.Unmarshal(manifest, &yamlMap) 107 | 108 | if err != nil { 109 | return 110 | } 111 | 112 | if !IsMappable(yamlMap) || len(yamlMap) == 0 { 113 | err = fmt.Errorf("Invalid manifest. Expected a map") 114 | return 115 | } 116 | 117 | return 118 | } 119 | 120 | func (manifestReader *FileManifestReader) interpetManifestPath(userSpecifiedPath string) (string, error) { 121 | fileInfo, err := os.Stat(userSpecifiedPath) 122 | if err != nil { 123 | return "", err 124 | } 125 | 126 | // If we've been given a directory, check inside it for manifest.yml/manifest.yaml files. 127 | if fileInfo.IsDir() { 128 | manifestPaths := []string{ 129 | filepath.Join(userSpecifiedPath, "manifest.yml"), 130 | filepath.Join(userSpecifiedPath, "manifest.yaml"), 131 | } 132 | var err error 133 | for _, manifestPath := range manifestPaths { 134 | if _, err = os.Stat(manifestPath); err == nil { 135 | return manifestPath, err 136 | } 137 | } 138 | return "", err 139 | } 140 | // If we didn't get a directory, assume we've been passed the file we want, so 141 | // just give that back. 142 | return userSpecifiedPath, nil 143 | } 144 | -------------------------------------------------------------------------------- /manifest/merge_reduce.go: -------------------------------------------------------------------------------- 1 | // NOTICE: This is a derivative work of https://github.com/cloudfoundry/cli/blob/27a6d92bbcc298f73713983f0f798f3621ef8b1d/util/generic/merge_reduce.go 2 | package manifest 3 | 4 | import ( 5 | "fmt" 6 | "reflect" 7 | ) 8 | 9 | func DeepMerge(maps ...map[string]interface{}) (map[string]interface{}, error) { 10 | mergedmap := make(map[string]interface{}) 11 | return Reduce(maps, mergedmap, mergeReducer) 12 | } 13 | 14 | func mergeReducer(key string, val interface{}, reduced map[string]interface{}) (map[string]interface{}, error) { 15 | 16 | switch { 17 | 18 | case containsKey(reduced, key) == false: 19 | reduced[key] = val 20 | return reduced, nil 21 | 22 | case IsMappable(val): 23 | mVal, err := Mappify(val) 24 | if err != nil { 25 | return nil, err 26 | } 27 | mReduced, err := Mappify(reduced[key].(interface{})) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | maps := []map[string]interface{}{mReduced, mVal} 33 | mergedmap, err := Reduce(maps, make(map[string]interface{}), mergeReducer) 34 | if err != nil { 35 | return nil, err 36 | } 37 | reduced[key] = mergedmap 38 | return reduced, nil 39 | 40 | case IsSliceable(val): 41 | reduced[key] = append(reduced[key].([]interface{}), val.([]interface{})...) 42 | return reduced, nil 43 | 44 | default: 45 | reduced[key] = val 46 | return reduced, nil 47 | } 48 | } 49 | 50 | func containsKey(thing map[string]interface{}, key string) bool { 51 | if _, ok := thing[key]; ok { 52 | return true 53 | } 54 | return false 55 | } 56 | 57 | func IsSliceable(value interface{}) bool { 58 | if value == nil { 59 | return false 60 | } 61 | return reflect.TypeOf(value).Kind() == reflect.Slice 62 | } 63 | 64 | func IsMappable(value interface{}) bool { 65 | if value == nil { 66 | return false 67 | } 68 | switch value.(type) { 69 | case map[string]interface{}: 70 | return true 71 | default: 72 | return reflect.TypeOf(value).Kind() == reflect.Map 73 | } 74 | } 75 | 76 | func arrayMappify(data []interface{}) map[string]interface{} { 77 | if len(data) == 0 { 78 | return make(map[string]interface{}) 79 | } else if len(data) > 1 { 80 | panic("Mappify called with more than one argument") 81 | } 82 | 83 | switch data := data[0].(type) { 84 | case nil: 85 | return make(map[string]interface{}) 86 | case map[string]string: 87 | stringToInterfaceMap := make(map[string]interface{}) 88 | 89 | for key, val := range data { 90 | stringToInterfaceMap[key] = val 91 | } 92 | return stringToInterfaceMap 93 | case map[string]interface{}: 94 | return data 95 | } 96 | 97 | panic("Mappify called with unexpected argument") 98 | } 99 | 100 | func Mappify(data interface{}) (map[string]interface{}, error) { 101 | 102 | switch data.(type) { 103 | case nil: 104 | return make(map[string]interface{}), nil 105 | case map[string]string: 106 | stringToInterfaceMap := make(map[string]interface{}) 107 | 108 | for key, val := range data.(map[string]string) { 109 | stringToInterfaceMap[key] = val 110 | } 111 | return stringToInterfaceMap, nil 112 | case map[string]interface{}: 113 | return data.(map[string]interface{}), nil 114 | case map[interface{}]interface{}: 115 | stringToInterfaceMap := make(map[string]interface{}) 116 | 117 | for key, val := range data.(map[interface{}]interface{}) { 118 | stringedKey := fmt.Sprintf("%v", key) 119 | stringToInterfaceMap[stringedKey] = val 120 | } 121 | return stringToInterfaceMap, nil 122 | 123 | } 124 | return nil, fmt.Errorf("Mappify called with unexpected argument of type %T, expected map", data) 125 | } 126 | 127 | type Reducer func(key string, val interface{}, reducedVal map[string]interface{}) (map[string]interface{}, error) 128 | 129 | func Reduce(collections []map[string]interface{}, resultVal map[string]interface{}, cb Reducer) (map[string]interface{}, error) { 130 | var err error 131 | for _, collection := range collections { 132 | for key := range collection { 133 | resultVal, err = cb(key, collection[key], resultVal) 134 | if err != nil { 135 | return nil, err 136 | } 137 | } 138 | } 139 | return resultVal, nil 140 | } 141 | -------------------------------------------------------------------------------- /script/ci/bluemix-devops/ci_release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | dir=`dirname $0` 5 | . $dir/ci_env 6 | 7 | git config --global user.email "$PIPELINE_TRIGGERING_USER" 8 | git config --global user.name "Build pipeline" 9 | 10 | DRAFTINESS=false 11 | 12 | # Create a git tag and push the tags 13 | 14 | if [ $DRY_RUN ]; then 15 | echo -- RUNNING WITH DRY_RUN=TRUE. -- 16 | echo -- A RELEASE WILL NOT ACTUALLY BE DONE. -- 17 | cmd_modifier='echo Would have run ... ' 18 | DRAFTINESS=true 19 | fi 20 | 21 | git clone https://github.com/fsaintjacques/semver-tool 22 | pushd semver-tool 23 | git checkout tags/1.2.1 24 | popd 25 | 26 | SECURED_GIT_URL=`cat artefacts/.gitorigin | sed -e "s,https://.@,https://,g"` 27 | GIT_HASH=`cat artefacts/.gitcommithash` 28 | 29 | GIT_URL=`echo $SECURED_GIT_URL | sed -e "s,https://,https://bluemixgarage:$OAUTH_TOKEN@,g"` 30 | mkdir prerelease 31 | pushd prerelease 32 | git clone $SECURED_GIT_URL 33 | cd cf-blue-green-deploy 34 | git checkout $GIT_HASH 35 | 36 | version_to_release=`../../semver-tool/src/semver release` 37 | 38 | TAG=v${version_to_release} 39 | NAME="Version ${version_to_release}" 40 | # TODO it would, of course, be nicer not to hardcode the repo name, and we should be able to work it out 41 | REPO="bluemixgaragelondon/cf-blue-green-deploy" 42 | #TODO optional, release does it? git tag ${TAG} 43 | #${cmd_modifier} git push --tags 44 | 45 | # Upload the binaries to the git release 46 | 47 | # Create a git release using the tag 48 | 49 | description=`cat .releaseDescription` 50 | payload=$( 51 | jq --null-input \ 52 | --arg tag "$TAG" \ 53 | --arg name "$NAME" \ 54 | --arg body "$description" \ 55 | --argjson draft $DRAFTINESS \ 56 | '{ tag_name: $tag, name: $name, body: $body, draft: $draft }' 57 | ) 58 | 59 | response=$( 60 | ${cmd_modifier} curl \ 61 | --fail \ 62 | -X POST \ 63 | -H "Content-Type: application/json" \ 64 | -H "Authorization: token $OAUTH_TOKEN" \ 65 | --netrc \ 66 | --location \ 67 | --data "$payload" \ 68 | "https://api.github.com/repos/${REPO}/releases" 69 | ) 70 | 71 | # In dry run mode, the response is empty so everything from here on fails - fake up a response from previous releases 72 | if [ $DRY_RUN ]; then 73 | echo "Using a previous release for dry-run purposes" 74 | response=$(curl "https://api.github.com/repos/${REPO}/releases" | jq '.[0]') 75 | fi 76 | 77 | upload_url="$(echo "$response" | jq -r .upload_url | sed -e "s/{?name,label}//")" 78 | 79 | echo " binaries:" > example-index.yml 80 | 81 | ARTEFACT_DIRECTORY=../../artefacts 82 | find $ARTEFACT_DIRECTORY -type f ! -iname '.*' | while read binary_path 83 | do 84 | binary_name=${binary_path##*/} 85 | binary_target=${binary_name#*.} 86 | 87 | sha=$(shasum "${binary_path}" | awk '{print $1}') 88 | 89 | ${cmd_modifier} curl --netrc \ 90 | --fail \ 91 | --header "Content-Type:application/gzip" \ 92 | -H "Authorization: token $OAUTH_TOKEN" \ 93 | --data-binary "@$binary_path" \ 94 | "$upload_url?name=$binary_name" 95 | 96 | # Output suitable json for pasting into the cli plugin repo 97 | echo " - checksum: ${sha}" >> example-index.yml 98 | echo " platform: ${binary_target}" >> example-index.yml 99 | echo " url: $GIT_URL/releases/download/$TAG/${binary_name}" >> example-index.yml 100 | 101 | done 102 | 103 | timestamp=`date +%Y-%m-%dT%H:%M:%SZ` 104 | echo " company: IBM" >> example-index.yml 105 | echo " created: 2015-03-24T00:00:00Z" >> example-index.yml 106 | echo " description: Zero downtime deploys with smoke test support" >> example-index.yml 107 | echo " homepage: https://github.com/bluemixgaragelondon/cf-blue-green-deploy" >> example-index.yml 108 | echo " name: blue-green-deploy" >> example-index.yml 109 | echo " updated: ${timestamp}" >> example-index.yml 110 | echo " version: ${version_to_release}" >> example-index.yml 111 | 112 | echo "----- The following would be suitable to paste into repo-index.yml -----" 113 | cat example-index.yml 114 | echo "---------" 115 | 116 | popd 117 | 118 | pushd prerelease/cf-blue-green-deploy 119 | git checkout master 120 | # Bump the stored version 121 | ../../semver-tool/src/semver bump minor 122 | # Commit back the next version to source control (with an rc tag) 123 | ../../semver-tool/src/semver bump prerel rc1 124 | echo "New working version is `cat .version`" 125 | git add .version 126 | # Clear the stored release description 127 | echo 'New release description here' > .releaseDescription 128 | echo "Cleared release description." 129 | # TODO it would be nice to fail the next release if this hasn't been updated 130 | git add .releaseDescription 131 | git commit -m "Auto release prep." 132 | ${cmd_modifier} git push 133 | popd 134 | 135 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | 10 | "code.cloudfoundry.org/cli/plugin" 11 | "code.cloudfoundry.org/cli/plugin/models" 12 | "github.com/bluemixgaragelondon/cf-blue-green-deploy/manifest" 13 | ) 14 | 15 | var PluginVersion string 16 | 17 | type CfPlugin struct { 18 | Connection plugin.CliConnection 19 | Deployer BlueGreenDeployer 20 | } 21 | 22 | func (p *CfPlugin) Run(cliConnection plugin.CliConnection, args []string) { 23 | if len(args) > 0 && args[0] == "CLI-MESSAGE-UNINSTALL" { 24 | return 25 | } 26 | 27 | argsStruct := NewArgs(args) 28 | 29 | p.Connection = cliConnection 30 | 31 | cfDomains := manifest.CfDomains{} 32 | var err error 33 | 34 | cfDomains.SharedDomains, err = p.SharedDomains() 35 | if err != nil { 36 | log.Fatalf("Failed to get shared domains: %v", err) 37 | } 38 | 39 | if len(cfDomains.SharedDomains) < 1 { 40 | log.Fatalf("Failed to get default shared domain (no shared domains defined)") 41 | } else { 42 | cfDomains.DefaultDomain = cfDomains.SharedDomains[0] 43 | } 44 | 45 | cfDomains.PrivateDomains, err = p.PrivateDomains() 46 | if err != nil { 47 | log.Fatalf("Failed to get private domains: %v", err) 48 | } 49 | 50 | p.Deployer.Setup(cliConnection) 51 | 52 | if argsStruct.AppName == "" { 53 | log.Fatal("App name was empty, must be provided.") 54 | } 55 | 56 | reader := manifest.FileManifestReader{argsStruct.ManifestPath} 57 | if !p.Deploy(cfDomains, &reader, argsStruct) { 58 | log.Fatal("Smoke tests failed") 59 | } 60 | } 61 | 62 | func (p *CfPlugin) Deploy(cfDomains manifest.CfDomains, manifestReader manifest.ManifestReader, args Args) bool { 63 | appName := args.AppName 64 | 65 | p.Deployer.DeleteAllAppsExceptLiveApp(appName) 66 | liveAppName, liveAppRoutes := p.Deployer.LiveApp(appName) 67 | 68 | manifestScaleParameters := p.GetScaleFromManifest(appName, cfDomains, manifestReader) 69 | 70 | // TODO We're overloading 'new' here for both the staging app and the 'finished' app, which is confusing 71 | newAppRoutes := p.GetNewAppRoutes(args.AppName, cfDomains, manifestReader, liveAppRoutes) 72 | newAppName := appName + "-new" 73 | 74 | // Add route so that we can run the smoke tests 75 | tempRouteDomain := newAppRoutes[0].Domain 76 | tempRoute := plugin_models.GetApp_RouteSummary{Host: newAppName, Domain: tempRouteDomain} 77 | 78 | // If deploy is unsuccessful, p.ErrorFunc will be called which exits. 79 | p.Deployer.PushNewApp(newAppName, tempRoute, args.ManifestPath, manifestScaleParameters) 80 | 81 | if liveAppName != "" { 82 | p.Deployer.SetSshAccess(newAppName, p.Deployer.CheckSshEnablement(appName)) 83 | } 84 | promoteNewApp := true 85 | smokeTestScript := args.SmokeTestPath 86 | if smokeTestScript != "" { 87 | promoteNewApp = p.Deployer.RunSmokeTests(smokeTestScript, FQDN(tempRoute)) 88 | } 89 | 90 | p.Deployer.UnmapRoutesFromApp(newAppName, tempRoute) 91 | p.Deployer.DeleteRoutes(tempRoute) 92 | 93 | if promoteNewApp { 94 | // If there is a live app, we want to disassociate the routes with the old version of the app 95 | // and instead update the routes to use the new version. 96 | if liveAppName != "" { 97 | p.Deployer.MapRoutesToApp(newAppName, newAppRoutes...) 98 | p.Deployer.RenameApp(liveAppName, appName+"-old") 99 | p.Deployer.RenameApp(newAppName, appName) 100 | p.Deployer.UnmapRoutesFromApp(appName+"-old", liveAppRoutes...) 101 | } else { 102 | // If there is no live app, we only need to add our new routes. 103 | p.Deployer.MapRoutesToApp(newAppName, newAppRoutes...) 104 | p.Deployer.RenameApp(newAppName, appName) 105 | } 106 | if args.DeleteOldApps { 107 | p.Deployer.DeleteAllAppsExceptLiveAndFailedApp(appName) 108 | } 109 | return true 110 | } else { 111 | // We don't want to promote. Instead mark it as failed. 112 | p.Deployer.RenameApp(newAppName, appName+"-failed") 113 | return false 114 | } 115 | } 116 | 117 | func (p *CfPlugin) GetNewAppRoutes(appName string, cfDomains manifest.CfDomains, manifestReader manifest.ManifestReader, liveAppRoutes []plugin_models.GetApp_RouteSummary) []plugin_models.GetApp_RouteSummary { 118 | newAppRoutes := []plugin_models.GetApp_RouteSummary{} 119 | 120 | parsedManifest, err := manifestReader.Read() 121 | if err != nil { 122 | // This error should be handled properly 123 | fmt.Println(err) 124 | } 125 | 126 | if parsedManifest != nil { 127 | if appParams := parsedManifest.GetAppParams(appName, cfDomains); appParams != nil && appParams.Routes != nil { 128 | newAppRoutes = appParams.Routes 129 | } 130 | } 131 | 132 | uniqueRoutes := p.UnionRouteLists(newAppRoutes, liveAppRoutes) 133 | 134 | if len(uniqueRoutes) == 0 { 135 | uniqueRoutes = append(uniqueRoutes, plugin_models.GetApp_RouteSummary{Host: appName, Domain: plugin_models.GetApp_DomainFields{Name: cfDomains.DefaultDomain}}) 136 | } 137 | return uniqueRoutes 138 | } 139 | 140 | func (p *CfPlugin) GetScaleFromManifest(appName string, cfDomains manifest.CfDomains, 141 | manifestReader manifest.ManifestReader) (scaleParameters ScaleParameters) { 142 | parsedManifest, err := manifestReader.Read() 143 | if err != nil { 144 | // TODO: Handle this error nicely 145 | fmt.Println(err) 146 | } 147 | if parsedManifest != nil { 148 | manifestScaleParameters := parsedManifest.GetAppParams(appName, cfDomains) 149 | if manifestScaleParameters != nil { 150 | scaleParameters = ScaleParameters{ 151 | Memory: manifestScaleParameters.Memory, 152 | InstanceCount: manifestScaleParameters.InstanceCount, 153 | DiskQuota: manifestScaleParameters.DiskQuota, 154 | } 155 | } 156 | } 157 | return 158 | } 159 | 160 | 161 | func (p *CfPlugin) contains(list []plugin_models.GetApp_RouteSummary, value plugin_models.GetApp_RouteSummary) bool { 162 | for _, v := range list { 163 | if (v == value) { 164 | return true; 165 | } 166 | } 167 | return false; 168 | } 169 | 170 | func (p *CfPlugin) UnionRouteLists(listA []plugin_models.GetApp_RouteSummary, listB []plugin_models.GetApp_RouteSummary) []plugin_models.GetApp_RouteSummary { 171 | duplicateList := append(listA, listB...) 172 | 173 | uniqueRoutes := []plugin_models.GetApp_RouteSummary{} 174 | for _, route := range duplicateList { 175 | if (! p.contains(uniqueRoutes, route)) { 176 | uniqueRoutes = append(uniqueRoutes, route) 177 | } 178 | } 179 | 180 | return uniqueRoutes 181 | } 182 | 183 | func (p *CfPlugin) GetMetadata() plugin.PluginMetadata { 184 | var major, minor, build int 185 | fmt.Sscanf(PluginVersion, "%d.%d.%d", &major, &minor, &build) 186 | 187 | return plugin.PluginMetadata{ 188 | Name: "blue-green-deploy", 189 | Version: plugin.VersionType{ 190 | Major: major, 191 | Minor: minor, 192 | Build: build, 193 | }, 194 | Commands: []plugin.Command{ 195 | { 196 | Name: "blue-green-deploy", 197 | Alias: "bgd", 198 | HelpText: "Zero-downtime deploys with smoke tests", 199 | UsageDetails: plugin.Usage{ 200 | // TODO for manifests with multiple apps, a different smoke test is needed. The approach below would not work. 201 | // Perhaps we could name the smoke test in the manifest? 202 | Usage: "blue-green-deploy APP_NAME [--smoke-test TEST_SCRIPT] [-f MANIFEST_FILE] [--delete-old-apps]", 203 | Options: map[string]string{ 204 | "smoke-test": "The test script to run.", 205 | "f": "Path to manifest", 206 | "delete-old-apps": "Delete old app instance(s)", 207 | }, 208 | }, 209 | }, 210 | }, 211 | } 212 | } 213 | 214 | func (p *CfPlugin) PrivateDomains() (domains []string, apiErr error) { 215 | path := "/v2/private_domains" 216 | return p.listCfDomains(path) 217 | } 218 | 219 | func (p *CfPlugin) SharedDomains() (domains []string, apiErr error) { 220 | path := "/v2/shared_domains" 221 | return p.listCfDomains(path) 222 | } 223 | 224 | func (p *CfPlugin) listCfDomains(cfPath string) (domains []string, err error) { 225 | var res []string 226 | if res, err = p.Connection.CliCommandWithoutTerminalOutput("curl", cfPath); err != nil { 227 | return 228 | } 229 | 230 | response := struct { 231 | Resources []struct { 232 | Entity struct { 233 | Name string 234 | } 235 | } 236 | }{} 237 | 238 | var jsonString string 239 | jsonString = strings.Join(res, "\n") 240 | 241 | if err = json.Unmarshal([]byte(jsonString), &response); err != nil { 242 | return 243 | } 244 | 245 | for i, _ := range response.Resources { 246 | domains = append(domains, response.Resources[i].Entity.Name) 247 | } 248 | return 249 | } 250 | 251 | func FQDN(r plugin_models.GetApp_RouteSummary) string { 252 | return fmt.Sprintf("%v.%v", r.Host, r.Domain.Name) 253 | } 254 | 255 | func main() { 256 | 257 | log.SetFlags(0) 258 | 259 | p := CfPlugin{ 260 | Deployer: &BlueGreenDeploy{ 261 | ErrorFunc: func(message string, err error) { 262 | log.Fatalf("%v - %v", message, err) 263 | }, 264 | Out: os.Stdout, 265 | }, 266 | } 267 | 268 | // TODO issue #24 - (Rufus) - not sure if I'm using the plugin correctly, but if I build (go build) and run without arguments 269 | // I expected to see available arguments but instead the code panics. 270 | plugin.Start(&p) 271 | } 272 | -------------------------------------------------------------------------------- /blue_green_deploy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os/exec" 7 | "regexp" 8 | "strings" 9 | 10 | "code.cloudfoundry.org/cli/plugin" 11 | "code.cloudfoundry.org/cli/plugin/models" 12 | ) 13 | 14 | type ErrorHandler func(string, error) 15 | 16 | type BlueGreenDeployer interface { 17 | Setup(plugin.CliConnection) 18 | PushNewApp(string, plugin_models.GetApp_RouteSummary, string, ScaleParameters) 19 | DeleteAllAppsExceptLiveApp(string) 20 | DeleteAllAppsExceptLiveAndFailedApp(string) 21 | GetScaleParameters(string) (ScaleParameters, error) 22 | LiveApp(string) (string, []plugin_models.GetApp_RouteSummary) 23 | RunSmokeTests(string, string) bool 24 | UnmapRoutesFromApp(string, ...plugin_models.GetApp_RouteSummary) 25 | DeleteRoutes(...plugin_models.GetApp_RouteSummary) 26 | RenameApp(string, string) 27 | MapRoutesToApp(string, ...plugin_models.GetApp_RouteSummary) 28 | CheckSshEnablement(string) bool 29 | SetSshAccess(string, bool) 30 | } 31 | 32 | type BlueGreenDeploy struct { 33 | Connection plugin.CliConnection 34 | Out io.Writer 35 | ErrorFunc ErrorHandler 36 | } 37 | 38 | type ScaleParameters struct { 39 | InstanceCount int 40 | Memory int64 41 | DiskQuota int64 42 | } 43 | 44 | func (p *BlueGreenDeploy) DeleteAppVersions(apps []plugin_models.GetAppsModel) { 45 | for _, app := range apps { 46 | if _, err := p.Connection.CliCommand("delete", app.Name, "-f", "-r"); err != nil { 47 | p.ErrorFunc("Could not delete old app version", err) 48 | } 49 | } 50 | } 51 | 52 | func (p *BlueGreenDeploy) DeleteAllAppsExceptLiveApp(appName string) { 53 | appsInSpace, err := p.Connection.GetApps() 54 | if err != nil { 55 | p.ErrorFunc("Could not load apps in space, are you logged in?", err) 56 | } 57 | oldAppVersions := p.GetOldApps(appName, appsInSpace) 58 | p.DeleteAppVersions(oldAppVersions) 59 | 60 | } 61 | 62 | func (p *BlueGreenDeploy) DeleteAllAppsExceptLiveAndFailedApp(appName string) { 63 | appsInSpace, err := p.Connection.GetApps() 64 | if err != nil { 65 | p.ErrorFunc("Could not load apps in space, are you logged in?", err) 66 | } 67 | oldAppVersions := p.GetOldButNotFailedApps(appName, appsInSpace) 68 | p.DeleteAppVersions(oldAppVersions) 69 | 70 | } 71 | 72 | func (p *BlueGreenDeploy) GetScaleParameters(appName string) (ScaleParameters, error) { 73 | appModel, err := p.Connection.GetApp(appName) 74 | if err != nil { 75 | return ScaleParameters{}, fmt.Errorf("Could not get scale parameters") 76 | } 77 | scaleParameters := ScaleParameters{ 78 | InstanceCount: appModel.InstanceCount, 79 | Memory: appModel.Memory, 80 | DiskQuota: appModel.DiskQuota, 81 | } 82 | return scaleParameters, nil 83 | } 84 | 85 | func mergeScaleParameters(liveScale, manifestScale ScaleParameters) ScaleParameters { 86 | scaleParameters := liveScale 87 | if manifestScale.Memory != 0 { 88 | scaleParameters.Memory = manifestScale.Memory 89 | } 90 | if manifestScale.InstanceCount != 0 { 91 | scaleParameters.InstanceCount = manifestScale.InstanceCount 92 | } 93 | if manifestScale.DiskQuota != 0 { 94 | scaleParameters.DiskQuota = manifestScale.DiskQuota 95 | } 96 | return scaleParameters 97 | } 98 | 99 | func appendScaleArguments(args []string, scaleParameters ScaleParameters) []string { 100 | if scaleParameters.InstanceCount != 0 { 101 | instanceCount := fmt.Sprintf("%d", scaleParameters.InstanceCount) 102 | args = append(args, "-i", instanceCount) 103 | } 104 | if scaleParameters.Memory != 0 { 105 | memory := fmt.Sprintf("%dM", scaleParameters.Memory) 106 | args = append(args, "-m", memory) 107 | } 108 | if scaleParameters.DiskQuota != 0 { 109 | diskQuota := fmt.Sprintf("%dM", scaleParameters.DiskQuota) 110 | args = append(args, "-k", diskQuota) 111 | } 112 | return args 113 | } 114 | 115 | func (p *BlueGreenDeploy) PushNewApp(appName string, route plugin_models.GetApp_RouteSummary, 116 | manifestPath string, scaleParameters ScaleParameters) { 117 | args := []string{"push", appName, "-n", route.Host, "-d", route.Domain.Name} 118 | 119 | // Remove -new suffix of appname to get live app name 120 | newAppSuffix := "-new" 121 | liveAppName := appName[:len(appName)-len(newAppSuffix)] 122 | liveScaleParameters, _ := p.GetScaleParameters(liveAppName) 123 | scaleParameters = mergeScaleParameters(liveScaleParameters, scaleParameters) 124 | 125 | args = appendScaleArguments(args, scaleParameters) 126 | if manifestPath != "" { 127 | args = append(args, "-f", manifestPath) 128 | } 129 | if _, err := p.Connection.CliCommand(args...); err != nil { 130 | p.ErrorFunc("Could not push new version", err) 131 | } 132 | } 133 | 134 | func (p *BlueGreenDeploy) GetOldApps(appName string, apps []plugin_models.GetAppsModel) (oldApps []plugin_models.GetAppsModel) { 135 | r := regexp.MustCompile(fmt.Sprintf("^%s(-old|-failed|-new)?$", appName)) 136 | for _, app := range apps { 137 | if !r.MatchString(app.Name) { 138 | continue 139 | } 140 | 141 | // TODO (Rufus) - perhaps a change in the regex is needed. 142 | // - e.g. `^%s-(old|failed|new)$` (making the capture group not optional). This would mean that the live app, if that is the version 143 | // with no prefix, is not matched but others are. Equally, if the live app is the one without a suffix, perhaps it would be sufficient 144 | // to check for the existence of a hyphen, in which case we could use something like strings.Count for hyphen instead of the regex. 145 | // Then we would not need the if statement below. 146 | if strings.HasSuffix(app.Name, "-old") || strings.HasSuffix(app.Name, "-failed") || strings.HasSuffix(app.Name, "-new") { 147 | oldApps = append(oldApps, app) 148 | } 149 | } 150 | return 151 | } 152 | 153 | func (p *BlueGreenDeploy) GetOldButNotFailedApps(appName string, apps []plugin_models.GetAppsModel) (oldApps []plugin_models.GetAppsModel) { 154 | r := regexp.MustCompile(fmt.Sprintf("^%s(-old|-new)?$", appName)) 155 | for _, app := range apps { 156 | if !r.MatchString(app.Name) { 157 | continue 158 | } 159 | 160 | // TODO (Rufus) - perhaps a change in the regex is needed. 161 | // - e.g. `^%s-(old|failed|new)$` (making the capture group not optional). This would mean that the live app, if that is the version 162 | // with no prefix, is not matched but others are. Equally, if the live app is the one without a suffix, perhaps it would be sufficient 163 | // to check for the existence of a hyphen, in which case we could use something like strings.Count for hyphen instead of the regex. 164 | // Then we would not need the if statement below. 165 | if strings.HasSuffix(app.Name, "-old") || strings.HasSuffix(app.Name, "-new") { 166 | oldApps = append(oldApps, app) 167 | } 168 | } 169 | return 170 | } 171 | 172 | func (p *BlueGreenDeploy) LiveApp(appName string) (string, []plugin_models.GetApp_RouteSummary) { 173 | 174 | // Don't worry about error handling since earlier calls would have flushed out any errors 175 | // except for ones that the app doesn't exist (which isn't an error condition for us) 176 | liveApp, _ := p.Connection.GetApp(appName) 177 | return liveApp.Name, liveApp.Routes 178 | } 179 | 180 | func (p *BlueGreenDeploy) Setup(connection plugin.CliConnection) { 181 | p.Connection = connection 182 | } 183 | 184 | func (p *BlueGreenDeploy) RunSmokeTests(script, appFQDN string) bool { 185 | out, err := exec.Command(script, appFQDN).CombinedOutput() 186 | fmt.Fprintln(p.Out, string(out)) 187 | 188 | if err != nil { 189 | if _, ok := err.(*exec.ExitError); ok { 190 | return false 191 | } else { 192 | p.ErrorFunc("Smoke tests failed", err) 193 | } 194 | } 195 | return true 196 | } 197 | 198 | func (p *BlueGreenDeploy) UnmapRoutesFromApp(oldAppName string, routes ...plugin_models.GetApp_RouteSummary) { 199 | for _, route := range routes { 200 | p.unmapRoute(oldAppName, route) 201 | } 202 | } 203 | 204 | func (p *BlueGreenDeploy) DeleteRoutes(routes ...plugin_models.GetApp_RouteSummary) { 205 | for _, route := range routes { 206 | p.deleteRoute(route) 207 | } 208 | } 209 | 210 | func (p *BlueGreenDeploy) mapRoute(appName string, r plugin_models.GetApp_RouteSummary) { 211 | if _, err := p.Connection.CliCommand("map-route", appName, r.Domain.Name, "-n", r.Host); err != nil { 212 | p.ErrorFunc("Could not map route", err) 213 | } 214 | } 215 | 216 | func (p *BlueGreenDeploy) unmapRoute(appName string, r plugin_models.GetApp_RouteSummary) { 217 | command := []string{"unmap-route", appName, r.Domain.Name, "-n", r.Host} 218 | if len(r.Path) != 0 { 219 | command = append(command, "--path") 220 | command = append(command, r.Path) 221 | } 222 | if _, err := p.Connection.CliCommand(command...); err != nil { 223 | p.ErrorFunc("Could not unmap route", err) 224 | } 225 | } 226 | 227 | func (p *BlueGreenDeploy) deleteRoute(r plugin_models.GetApp_RouteSummary) { 228 | if _, err := p.Connection.CliCommand("delete-route", r.Domain.Name, "-n", r.Host, "-f"); err != nil { 229 | p.ErrorFunc("Could not delete route", err) 230 | } 231 | } 232 | 233 | func (p *BlueGreenDeploy) RenameApp(app string, newName string) { 234 | if _, err := p.Connection.CliCommand("rename", app, newName); err != nil { 235 | p.ErrorFunc("Could not rename app", err) 236 | } 237 | } 238 | 239 | func (p *BlueGreenDeploy) MapRoutesToApp(appName string, routes ...plugin_models.GetApp_RouteSummary) { 240 | for _, route := range routes { 241 | p.mapRoute(appName, route) 242 | } 243 | } 244 | 245 | func (p *BlueGreenDeploy) CheckSshEnablement(app string) bool { 246 | if result, err := p.Connection.CliCommand("ssh-enabled", app); err != nil { 247 | p.ErrorFunc("Check ssh enabled status failed", err) 248 | return true 249 | } else { 250 | return (strings.Contains(result[0], "support is enabled")) 251 | } 252 | } 253 | 254 | func (p *BlueGreenDeploy) SetSshAccess(app string, enableSsh bool) { 255 | if enableSsh { 256 | if _, err := p.Connection.CliCommand("enable-ssh", app); err != nil { 257 | p.ErrorFunc("Could not enable ssh", err) 258 | } 259 | } else { 260 | if _, err := p.Connection.CliCommand("disable-ssh", app); err != nil { 261 | p.ErrorFunc("Could not disable ssh", err) 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2015 IBM Corp. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /manifest/manifest_test.go: -------------------------------------------------------------------------------- 1 | package manifest 2 | 3 | import ( 4 | "code.cloudfoundry.org/cli/plugin/models" 5 | "github.com/cloudfoundry-incubator/candiedyaml" 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("Manifest", func() { 11 | Context("For a manifest", func() { 12 | It("parses known manifest keys", func() { 13 | m := &Manifest{ 14 | Path: "./manifest.yml", 15 | Data: map[string]interface{}{ 16 | "disk_quota": "512M", 17 | "memory": "256M", 18 | "instances": 1, 19 | }, 20 | } 21 | apps, err := m.Applications(CfDomains{}) 22 | Expect(err).NotTo(HaveOccurred()) 23 | Expect(len(apps)).To(Equal(1)) 24 | 25 | Expect(apps[0].DiskQuota).To(Equal(int64(512))) 26 | Expect(apps[0].Memory).To(Equal(int64(256))) 27 | Expect(apps[0].InstanceCount).To(Equal(1)) 28 | }) 29 | }) 30 | 31 | Context("For a manifest with no applications section", func() { 32 | 33 | input := map[string]interface{}{ 34 | "host": "bob", 35 | "routes": []interface{}{ 36 | map[interface{}]interface{}{"route": "example.com"}, 37 | map[interface{}]interface{}{"route": "www.example.com/foo"}, 38 | map[interface{}]interface{}{"route": "tcp-example.com:1234"}, 39 | }, 40 | } 41 | m := &Manifest{} 42 | 43 | Context("the getAppMaps function", func() { 44 | appMaps, err := m.getAppMaps(input) 45 | It("does not error", func() { 46 | Expect(err).To(BeNil()) 47 | }) 48 | 49 | It("should return one entry", func() { 50 | Expect(len(appMaps)).To(Equal(1)) 51 | }) 52 | 53 | It("should return global properties", func() { 54 | Expect(appMaps).To(Equal([]map[string]interface{}{input})) 55 | }) 56 | }) 57 | 58 | Context("the parseRoutes function", func() { 59 | errs := []error{} 60 | routeStuff := parseRoutes(CfDomains{SharedDomains: []string{"example.com"}, PrivateDomains: []string{"tcp-example.com"}}, input, &errs) 61 | 62 | It("does not error", func() { 63 | Expect(len(errs)).To(Equal(0)) 64 | }) 65 | 66 | It("should return three routes", func() { 67 | Expect(len(routeStuff)).To(Equal(3)) 68 | }) 69 | 70 | It("should return global properties", func() { 71 | // We're only testing for domain because of limitations in the route struct 72 | Expect(routeStuff[0].Domain.Name).To(Equal("example.com")) 73 | }) 74 | }) 75 | }) 76 | 77 | Context("For a manifest with an applications section", func() { 78 | applicationsContents := []interface{}{map[string]string{ 79 | "fred": "hello", 80 | }} 81 | input := map[string]interface{}{ 82 | "applications": applicationsContents, 83 | "host": "bob", 84 | } 85 | 86 | m := &Manifest{} 87 | appMaps, err := m.getAppMaps(input) 88 | 89 | Context("the AppMaps function", func() { 90 | It("does not error", func() { 91 | Expect(err).To(BeNil()) 92 | }) 93 | 94 | It("should not alter what gets passed in", func() { 95 | 96 | Expect(input["applications"]).To(Equal(applicationsContents)) 97 | // Make sure this doesn't change what's passed in 98 | Expect(input["applications"]).To(Equal(applicationsContents)) 99 | 100 | }) 101 | 102 | It("should return one entry", func() { 103 | Expect(len(appMaps)).To(Equal(1)) 104 | }) 105 | 106 | It("should merge global properties with application-level properties", func() { 107 | 108 | Expect(appMaps[0]["host"]).To(Equal("bob")) 109 | Expect(appMaps[0]["fred"]).To(Equal("hello")) 110 | 111 | }) 112 | }) 113 | }) 114 | 115 | Context("For a manifest with two applications in the applications section", func() { 116 | applicationsContents := []interface{}{map[string]string{ 117 | "fred": "hello", 118 | }, 119 | map[string]string{ 120 | "george": "goodbye", 121 | }} 122 | input := map[string]interface{}{ 123 | "applications": applicationsContents, 124 | "host": "bob", 125 | } 126 | 127 | m := &Manifest{} 128 | appMaps, err := m.getAppMaps(input) 129 | 130 | Context("the AppMaps function", func() { 131 | It("does not error", func() { 132 | Expect(err).To(BeNil()) 133 | }) 134 | 135 | It("should not alter what gets passed in", func() { 136 | 137 | Expect(input["applications"]).To(Equal(applicationsContents)) 138 | // Make sure this doesn't change what's passed in 139 | Expect(input["applications"]).To(Equal(applicationsContents)) 140 | 141 | }) 142 | 143 | It("should return two entry", func() { 144 | Expect(len(appMaps)).To(Equal(2)) 145 | }) 146 | 147 | It("should merge global properties with application-level properties", func() { 148 | 149 | Expect(appMaps[0]["host"]).To(Equal("bob")) 150 | Expect(appMaps[0]["fred"]).To(Equal("hello")) 151 | Expect(appMaps[0]["george"]).To(BeNil()) 152 | 153 | Expect(appMaps[1]["host"]).To(Equal("bob")) 154 | Expect(appMaps[1]["george"]).To(Equal("goodbye")) 155 | Expect(appMaps[1]["fred"]).To(BeNil()) 156 | 157 | }) 158 | }) 159 | }) 160 | 161 | }) 162 | 163 | var _ = Describe("CloneWithExclude", func() { 164 | 165 | Context("When the map contains some values and excludeKey exists", func() { 166 | 167 | input := map[string]interface{}{ 168 | "one": 1, 169 | "two": 2138, 170 | "three": 1908, 171 | } 172 | 173 | excludeKey := "two" 174 | 175 | actual := cloneWithExclude(input, excludeKey) 176 | 177 | It("should return a new map without the excludeKey", func() { 178 | 179 | expected := map[string]interface{}{ 180 | "one": 1, 181 | "three": 1908, 182 | } 183 | 184 | Expect(actual).To(Equal(expected)) 185 | }) 186 | 187 | It("should not alter the original map", func() { 188 | Expect(input["two"]).To(Equal(2138)) 189 | }) 190 | }) 191 | 192 | Context("When the map contains some values and excludeKey does not exist", func() { 193 | It("should return a new map with the same contents as the original", func() { 194 | input := map[string]interface{}{ 195 | "one": 1, 196 | "two": 2138, 197 | "three": 1908, 198 | } 199 | 200 | excludeKey := "four" 201 | 202 | actual := cloneWithExclude(input, excludeKey) 203 | 204 | Expect(actual).To(Equal(input)) 205 | }) 206 | }) 207 | 208 | Context("When the map contains a key that includes the excludeKey", func() { 209 | It("should return a new map with the same contents as the original", func() { 210 | input := map[string]interface{}{ 211 | "one": 1, 212 | "two": 2138, 213 | "threefour": 1908, 214 | } 215 | 216 | excludeKey := "four" 217 | 218 | actual := cloneWithExclude(input, excludeKey) 219 | 220 | Expect(actual).To(Equal(input)) 221 | }) 222 | }) 223 | 224 | Context("When the map is empty", func() { 225 | It("should return a new empty map", func() { 226 | input := map[string]interface{}{} 227 | 228 | excludeKey := "one" 229 | 230 | actual := cloneWithExclude(input, excludeKey) 231 | 232 | Expect(actual).To(Equal(input)) 233 | }) 234 | }) 235 | 236 | Context("when the manifest contains a different app name", func() { 237 | manifest := manifestFromYamlString(`--- 238 | name: bar 239 | host: foo`) 240 | 241 | It("Returns nil", func() { 242 | Expect(manifest.GetAppParams("appname", CfDomains{DefaultDomain: "domain"})).To(BeNil()) 243 | }) 244 | 245 | Context("when the manifest contain a host but no app name", func() { 246 | manifest := manifestFromYamlString(`--- 247 | host: foo`) 248 | 249 | It("Returns params that contain the host", func() { 250 | 251 | routes := manifest.GetAppParams("foo", CfDomains{DefaultDomain: "something.com"}).Routes 252 | Expect(routes).To(ConsistOf( 253 | plugin_models.GetApp_RouteSummary{Host: "foo", Domain: plugin_models.GetApp_DomainFields{Name: "something.com"}}, 254 | )) 255 | }) 256 | }) 257 | 258 | Describe("Route Lister", func() { 259 | It("returns a list of Routes from the manifest", func() { 260 | manifest := manifestFromYamlString(`--- 261 | name: foo 262 | hosts: 263 | - host1 264 | - host2 265 | domains: 266 | - example.com 267 | - example.net`) 268 | 269 | params := manifest.GetAppParams("foo", CfDomains{DefaultDomain: "example.com"}) 270 | 271 | Expect(params).ToNot(BeNil()) 272 | Expect(params.Routes).ToNot(BeNil()) 273 | 274 | routes := params.Routes 275 | Expect(routes).To(ConsistOf( 276 | plugin_models.GetApp_RouteSummary{Host: "host1", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 277 | plugin_models.GetApp_RouteSummary{Host: "host1", Domain: plugin_models.GetApp_DomainFields{Name: "example.net"}}, 278 | plugin_models.GetApp_RouteSummary{Host: "host2", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 279 | plugin_models.GetApp_RouteSummary{Host: "host2", Domain: plugin_models.GetApp_DomainFields{Name: "example.net"}}, 280 | )) 281 | }) 282 | 283 | Context("when app has just hosts, no domains", func() { 284 | It("returns Application", func() { 285 | manifest := manifestFromYamlString(`--- 286 | name: foo 287 | hosts: 288 | - host1 289 | - host2`) 290 | 291 | params := manifest.GetAppParams("foo", CfDomains{DefaultDomain: "example.com"}) 292 | Expect(params).ToNot(BeNil()) 293 | Expect(params.Routes).ToNot(BeNil()) 294 | 295 | routes := params.Routes 296 | Expect(routes).To(ConsistOf( 297 | plugin_models.GetApp_RouteSummary{Host: "host1", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 298 | plugin_models.GetApp_RouteSummary{Host: "host2", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 299 | )) 300 | }) 301 | }) 302 | 303 | Context("when app has just one host and one domain", func() { 304 | It("returns Application", func() { 305 | manifest := manifestFromYamlString(`--- 306 | name: foo 307 | host: host 308 | domain: domain.com`) 309 | 310 | params := manifest.GetAppParams("foo", CfDomains{DefaultDomain: "example.com"}) 311 | Expect(params).ToNot(BeNil()) 312 | Expect(params.Routes).ToNot(BeNil()) 313 | 314 | routes := params.Routes 315 | Expect(routes).To(ConsistOf( 316 | plugin_models.GetApp_RouteSummary{Host: "host", Domain: plugin_models.GetApp_DomainFields{Name: "domain.com"}}, 317 | )) 318 | }) 319 | }) 320 | 321 | Context("when app has just routes, no hosts or domains", func() { 322 | It("returns those routes", func() { 323 | manifest := manifestFromYamlString(`--- 324 | name: foo 325 | routes: 326 | - route: route1.domain1 327 | - route: route2.domain2`) 328 | 329 | params := manifest.GetAppParams("foo", CfDomains{DefaultDomain: "example.com", SharedDomains: []string{"route1.domain1", "route2.domain2"}}) 330 | Expect(params).ToNot(BeNil()) 331 | Expect(params.Routes).ToNot(BeNil()) 332 | 333 | routes := params.Routes 334 | Expect(routes).To(ConsistOf( 335 | plugin_models.GetApp_RouteSummary{Domain: plugin_models.GetApp_DomainFields{Name: "route1.domain1"}}, 336 | plugin_models.GetApp_RouteSummary{Domain: plugin_models.GetApp_DomainFields{Name: "route2.domain2"}}, 337 | )) 338 | }) 339 | }) 340 | 341 | Context("when app has routes, and an app name, but no domain", func() { 342 | It("correctly identifies the host and domain components", func() { 343 | 344 | manifest := manifestFromYamlString(`--- 345 | name: my-app 346 | routes: 347 | - route: my-app.example.io`) 348 | 349 | params := manifest.GetAppParams("my-app", CfDomains{DefaultDomain: "defaultdomain.com", PrivateDomains: []string{"example.io"}}) 350 | Expect(params).ToNot(BeNil()) 351 | Expect(params.Routes).ToNot(BeNil()) 352 | 353 | routes := params.Routes 354 | Expect(routes).To(ConsistOf( 355 | plugin_models.GetApp_RouteSummary{Host: "my-app", Domain: plugin_models.GetApp_DomainFields{Name: "example.io"}}, 356 | )) 357 | }) 358 | 359 | Context("the app name is repeated in the domain", func() { 360 | It("correctly identifies the host and domain components", func() { 361 | 362 | manifest := manifestFromYamlString(`--- 363 | name: my-app 364 | routes: 365 | - route: my-app.example.my-app.io`) 366 | 367 | params := manifest.GetAppParams("my-app", CfDomains{DefaultDomain: "defaultdomain.com", PrivateDomains: []string{"example.my-app.io"}}) 368 | Expect(params).ToNot(BeNil()) 369 | Expect(params.Routes).ToNot(BeNil()) 370 | 371 | routes := params.Routes 372 | Expect(routes).To(ConsistOf( 373 | plugin_models.GetApp_RouteSummary{Host: "my-app", Domain: plugin_models.GetApp_DomainFields{Name: "example.my-app.io"}}, 374 | )) 375 | }) 376 | }) 377 | }) 378 | 379 | Context("when no matching application", func() { 380 | It("returns nil", func() { 381 | manifest := manifestFromYamlString(``) 382 | 383 | Expect(manifest.GetAppParams("foo", CfDomains{DefaultDomain: "example.com"})).To(BeNil()) 384 | }) 385 | }) 386 | }) 387 | 388 | }) 389 | 390 | Context("when the manifest contains multiple apps with 1 matching", func() { 391 | manifest := manifestFromYamlString(`--- 392 | applications: 393 | - name: bar 394 | host: barhost 395 | - name: foo 396 | hosts: 397 | - host1 398 | - host2 399 | domains: 400 | - example1.com 401 | - example2.com`) 402 | It("Returns the correct app", func() { 403 | 404 | var hostNames []string 405 | var domainNames []string 406 | 407 | appParams := manifest.GetAppParams("foo", CfDomains{}) 408 | Expect(appParams).ToNot(BeNil()) 409 | 410 | routes := appParams.Routes 411 | Expect(routes).ToNot(BeNil()) 412 | for _, route := range routes { 413 | hostNames = append(hostNames, route.Host) 414 | domainNames = append(domainNames, route.Domain.Name) 415 | } 416 | 417 | hostNames = deDuplicate(hostNames) 418 | domainNames = deDuplicate(domainNames) 419 | 420 | Expect(manifest.GetAppParams("foo", CfDomains{}).Name).To(Equal("foo")) 421 | Expect(hostNames).To(ConsistOf("host1", "host2")) 422 | Expect(domainNames).To(ConsistOf("example1.com", "example2.com")) 423 | }) 424 | }) 425 | }) 426 | 427 | func deDuplicate(ary []string) []string { 428 | if ary == nil { 429 | return nil 430 | } 431 | 432 | m := make(map[string]bool) 433 | for _, v := range ary { 434 | m[v] = true 435 | } 436 | 437 | newAry := []string{} 438 | for _, val := range ary { 439 | if m[val] { 440 | newAry = append(newAry, val) 441 | m[val] = false 442 | } 443 | } 444 | return newAry 445 | } 446 | 447 | func manifestFromYamlString(yamlString string) *Manifest { 448 | yamlMap := make(map[string]interface{}) 449 | candiedyaml.Unmarshal([]byte(yamlString), &yamlMap) 450 | return &Manifest{Data: yamlMap} 451 | } 452 | -------------------------------------------------------------------------------- /manifest/manifest.go: -------------------------------------------------------------------------------- 1 | // NOTICE: This is a derivative work of https://github.com/bluemixgaragelondon/cf-blue-green-deploy/blob/master/manifest.go. 2 | package manifest 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "path/filepath" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | 12 | "code.cloudfoundry.org/cli/cf/formatters" 13 | "code.cloudfoundry.org/cli/plugin/models" 14 | "github.com/Pallinder/go-randomdata" 15 | ) 16 | 17 | type Manifest struct { 18 | Path string 19 | Data map[string]interface{} 20 | } 21 | 22 | type CfDomains struct { 23 | DefaultDomain string 24 | SharedDomains []string 25 | PrivateDomains []string 26 | } 27 | 28 | func (m Manifest) Applications(cfDomains CfDomains) ([]plugin_models.GetAppModel, error) { 29 | 30 | rawData, err := expandProperties(m.Data) 31 | data := rawData.(map[string]interface{}) 32 | 33 | if err != nil { 34 | return []plugin_models.GetAppModel{}, err 35 | } 36 | 37 | appMaps, err := m.getAppMaps(data) 38 | if err != nil { 39 | return []plugin_models.GetAppModel{}, err 40 | } 41 | var apps []plugin_models.GetAppModel 42 | var mapToAppErrs []error 43 | for _, appMap := range appMaps { 44 | app, err := mapToAppParams(filepath.Dir(m.Path), appMap, cfDomains) 45 | if err != nil { 46 | mapToAppErrs = append(mapToAppErrs, err) 47 | continue 48 | } 49 | apps = append(apps, app) 50 | } 51 | 52 | if len(mapToAppErrs) > 0 { 53 | message := "" 54 | for i := range mapToAppErrs { 55 | message = message + fmt.Sprintf("%s\n", mapToAppErrs[i].Error()) 56 | } 57 | return []plugin_models.GetAppModel{}, errors.New(message) 58 | } 59 | 60 | return apps, nil 61 | } 62 | 63 | func cloneWithExclude(data map[string]interface{}, excludedKey string) map[string]interface{} { 64 | otherMap := make(map[string]interface{}) 65 | for key, value := range data { 66 | if excludedKey != key { 67 | otherMap[key] = value 68 | } 69 | } 70 | return otherMap 71 | } 72 | 73 | func (m Manifest) getAppMaps(data map[string]interface{}) ([]map[string]interface{}, error) { 74 | 75 | var apps []map[string]interface{} 76 | var errs []error 77 | // Check for presence 78 | unTypedAppMaps, ok := data["applications"] 79 | if ok { 80 | // Check for type 81 | appMaps, ok := unTypedAppMaps.([]interface{}) 82 | 83 | if !ok { 84 | return []map[string]interface{}{}, errors.New("Expected applications to be a list") 85 | } 86 | 87 | globalProperties := cloneWithExclude(data, "applications") 88 | 89 | for _, appData := range appMaps { 90 | appDataAsMap, err := Mappify(appData) 91 | if err != nil { 92 | errs = append(errs, fmt.Errorf("Expected application to be a list of key/value pairs\nError occurred in manifest near:\n'{{.YmlSnippet}}'. Error was %v", 93 | map[string]interface{}{"YmlSnippet": appData}, err)) 94 | continue 95 | } 96 | 97 | appMap, err := DeepMerge(globalProperties, appDataAsMap) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | apps = append(apps, appMap) 103 | } 104 | } else { 105 | // All properties in data are global, so just throw them in 106 | apps = append(apps, data) 107 | } 108 | 109 | if len(errs) > 0 { 110 | message := "" 111 | for i := range errs { 112 | message = message + fmt.Sprintf("%s\n", errs[i].Error()) 113 | } 114 | return []map[string]interface{}{}, errors.New(message) 115 | } 116 | 117 | return apps, nil 118 | } 119 | 120 | var propertyRegex = regexp.MustCompile(`\${[\w-]+}`) 121 | 122 | func expandProperties(input interface{}) (interface{}, error) { 123 | var errs []error 124 | var output interface{} 125 | 126 | switch input := input.(type) { 127 | case string: 128 | match := propertyRegex.FindStringSubmatch(input) 129 | if match != nil { 130 | if match[0] == "${random-word}" { 131 | // TODO we need a test for a manifest with ${random-word} 132 | output = strings.Replace(input, "${random-word}", strings.ToLower(randomdata.SillyName()), -1) 133 | } else { 134 | err := fmt.Errorf("Property '{{.PropertyName}}' found in manifest. This feature is no longer supported. Please remove it and try again.", 135 | map[string]interface{}{"PropertyName": match[0]}) 136 | errs = append(errs, err) 137 | } 138 | } else { 139 | output = input 140 | } 141 | case []interface{}: 142 | outputSlice := make([]interface{}, len(input)) 143 | for index, item := range input { 144 | itemOutput, itemErr := expandProperties(item) 145 | if itemErr != nil { 146 | errs = append(errs, itemErr) 147 | break 148 | } 149 | outputSlice[index] = itemOutput 150 | } 151 | output = outputSlice 152 | case map[interface{}]interface{}: 153 | 154 | outputMap := make(map[interface{}]interface{}) 155 | for key, value := range input { 156 | itemOutput, itemErr := expandProperties(value) 157 | if itemErr != nil { 158 | errs = append(errs, itemErr) 159 | break 160 | } 161 | outputMap[key] = itemOutput 162 | } 163 | output = outputMap 164 | case map[string]interface{}: 165 | 166 | outputMap := make(map[string]interface{}) 167 | for key, value := range input { 168 | 169 | itemOutput, itemErr := expandProperties(value) 170 | if itemErr != nil { 171 | errs = append(errs, itemErr) 172 | break 173 | } 174 | outputMap[key] = itemOutput 175 | } 176 | output = outputMap 177 | default: 178 | output = input 179 | } 180 | 181 | if len(errs) > 0 { 182 | message := "" 183 | for _, err := range errs { 184 | message = message + fmt.Sprintf("%s\n", err.Error()) 185 | } 186 | return nil, errors.New(message) 187 | } 188 | 189 | return output, nil 190 | } 191 | 192 | func mapToAppParams(basePath string, yamlMap map[string]interface{}, cfDomains CfDomains) (plugin_models.GetAppModel, error) { 193 | err := checkForNulls(yamlMap) 194 | if err != nil { 195 | return plugin_models.GetAppModel{}, err 196 | } 197 | 198 | var appParams plugin_models.GetAppModel 199 | var errs []error 200 | 201 | if diskQuota := bytesVal(yamlMap, "disk_quota", &errs); diskQuota != nil { 202 | appParams.DiskQuota = *diskQuota 203 | } 204 | 205 | domainAry := sliceOrNil(yamlMap, "domains", &errs) 206 | if domain := stringVal(yamlMap, "domain", &errs); domain != nil { 207 | domainAry = append(domainAry, *domain) 208 | } 209 | mytempDomainsObject := removeDuplicatedValue(domainAry) 210 | 211 | hostsArr := sliceOrNil(yamlMap, "hosts", &errs) 212 | if host := stringVal(yamlMap, "host", &errs); host != nil { 213 | hostsArr = append(hostsArr, *host) 214 | } 215 | myTempHostsObject := removeDuplicatedValue(hostsArr) 216 | 217 | routeRoutes := parseRoutes(cfDomains, yamlMap, &errs) 218 | compositeRoutes := RoutesFromManifest(cfDomains.DefaultDomain, myTempHostsObject, mytempDomainsObject) 219 | 220 | if routeRoutes == nil { 221 | appParams.Routes = compositeRoutes 222 | } else if compositeRoutes == nil || len(compositeRoutes) == 0 { 223 | appParams.Routes = routeRoutes 224 | } else { 225 | errs = append(errs, errors.New("Cannot have both a routes and a host or domain")) // TODO better message 226 | } 227 | if name := stringVal(yamlMap, "name", &errs); name != nil { 228 | appParams.Name = *name 229 | } 230 | 231 | if memory := bytesVal(yamlMap, "memory", &errs); memory != nil { 232 | appParams.Memory = *memory 233 | } 234 | 235 | if instanceCount := intVal(yamlMap, "instances", &errs); instanceCount != nil { 236 | appParams.InstanceCount = *instanceCount 237 | } 238 | 239 | if len(errs) > 0 { 240 | message := "" 241 | for _, err := range errs { 242 | message = message + fmt.Sprintf("%s\n", err.Error()) 243 | } 244 | return plugin_models.GetAppModel{}, errors.New(message) 245 | } 246 | return appParams, nil 247 | } 248 | 249 | func removeDuplicatedValue(ary []string) []string { 250 | if ary == nil { 251 | return nil 252 | } 253 | 254 | m := make(map[string]bool) 255 | for _, v := range ary { 256 | m[v] = true 257 | } 258 | 259 | newAry := []string{} 260 | for _, val := range ary { 261 | if m[val] { 262 | newAry = append(newAry, val) 263 | m[val] = false 264 | } 265 | } 266 | return newAry 267 | } 268 | 269 | func checkForNulls(yamlMap map[string]interface{}) error { 270 | var errs []error 271 | for key, value := range yamlMap { 272 | if key == "command" || key == "buildpack" { 273 | break 274 | } 275 | if value == nil { 276 | errs = append(errs, fmt.Errorf("{{.PropertyName}} should not be null", map[string]interface{}{"PropertyName": key})) 277 | } 278 | } 279 | 280 | if len(errs) > 0 { 281 | message := "" 282 | for i := range errs { 283 | message = message + fmt.Sprintf("%s\n", errs[i].Error()) 284 | } 285 | return errors.New(message) 286 | } 287 | 288 | return nil 289 | } 290 | 291 | func stringVal(yamlMap map[string]interface{}, key string, errs *[]error) *string { 292 | val := yamlMap[key] 293 | if val == nil { 294 | return nil 295 | } 296 | result, ok := val.(string) 297 | if !ok { 298 | *errs = append(*errs, fmt.Errorf("{{.PropertyName}} must be a string value", map[string]interface{}{"PropertyName": key})) 299 | return nil 300 | } 301 | return &result 302 | } 303 | 304 | func bytesVal(yamlMap map[string]interface{}, key string, errs *[]error) *int64 { 305 | yamlVal := yamlMap[key] 306 | if yamlVal == nil { 307 | return nil 308 | } 309 | 310 | stringVal := coerceToString(yamlVal) 311 | value, err := formatters.ToMegabytes(stringVal) 312 | if err != nil { 313 | *errs = append(*errs, fmt.Errorf("Invalid value for '{{.PropertyName}}': {{.StringVal}}\n{{.Error}}", 314 | map[string]interface{}{ 315 | "PropertyName": key, 316 | "Error": err.Error(), 317 | "StringVal": stringVal, 318 | })) 319 | return nil 320 | } 321 | return &value 322 | } 323 | 324 | func intVal(yamlMap map[string]interface{}, key string, errs *[]error) *int { 325 | var ( 326 | intVal int 327 | err error 328 | ) 329 | 330 | switch val := yamlMap[key].(type) { 331 | case string: 332 | intVal, err = strconv.Atoi(val) 333 | case int: 334 | intVal = val 335 | case int64: 336 | intVal = int(val) 337 | case nil: 338 | return nil 339 | default: 340 | err = fmt.Errorf("Expected {{.PropertyName}} to be a number, but it was a {{.PropertyType}}.", 341 | map[string]interface{}{"PropertyName": key, "PropertyType": val}) 342 | } 343 | 344 | if err != nil { 345 | *errs = append(*errs, err) 346 | return nil 347 | } 348 | 349 | return &intVal 350 | } 351 | 352 | func coerceToString(value interface{}) string { 353 | return fmt.Sprintf("%v", value) 354 | } 355 | 356 | func sliceOrNil(yamlMap map[string]interface{}, key string, errs *[]error) []string { 357 | if _, ok := yamlMap[key]; !ok { 358 | return nil 359 | } 360 | 361 | var err error 362 | stringSlice := []string{} 363 | 364 | sliceErr := fmt.Errorf("Expected {{.PropertyName}} to be a list of strings.", map[string]interface{}{"PropertyName": key}) 365 | 366 | switch input := yamlMap[key].(type) { 367 | case []interface{}: 368 | for _, value := range input { 369 | stringValue, ok := value.(string) 370 | if !ok { 371 | err = sliceErr 372 | break 373 | } 374 | stringSlice = append(stringSlice, stringValue) 375 | } 376 | default: 377 | err = sliceErr 378 | } 379 | 380 | if err != nil { 381 | *errs = append(*errs, err) 382 | return []string{} 383 | } 384 | 385 | return stringSlice 386 | } 387 | 388 | func RoutesFromManifest(defaultDomain string, Hosts []string, Domains []string) []plugin_models.GetApp_RouteSummary { 389 | 390 | manifestRoutes := make([]plugin_models.GetApp_RouteSummary, 0) 391 | 392 | for _, host := range Hosts { 393 | if Domains == nil { 394 | manifestRoutes = append(manifestRoutes, plugin_models.GetApp_RouteSummary{Host: host, Domain: plugin_models.GetApp_DomainFields{Name: defaultDomain}}) 395 | continue 396 | } 397 | 398 | for _, domain := range Domains { 399 | manifestRoutes = append(manifestRoutes, plugin_models.GetApp_RouteSummary{Host: host, Domain: plugin_models.GetApp_DomainFields{Name: domain}}) 400 | } 401 | } 402 | 403 | return manifestRoutes 404 | } 405 | 406 | func parseRoutes(cfDomains CfDomains, input map[string]interface{}, errs *[]error) []plugin_models.GetApp_RouteSummary { 407 | if _, ok := input["routes"]; !ok { 408 | return nil 409 | } 410 | 411 | genericRoutes, ok := input["routes"].([]interface{}) 412 | if !ok { 413 | *errs = append(*errs, fmt.Errorf("'routes' should be a list")) 414 | return nil 415 | } 416 | 417 | manifestRoutes := []plugin_models.GetApp_RouteSummary{} 418 | for _, genericRoute := range genericRoutes { 419 | route, ok := genericRoute.(map[interface{}]interface{}) 420 | if !ok { 421 | *errs = append(*errs, fmt.Errorf("each route in 'routes' must have a 'route' property")) 422 | continue 423 | } 424 | 425 | if routeVal, exist := route["route"]; exist { 426 | routeWithoutPath, path := findPath(routeVal.(string)) 427 | 428 | routeWithoutPathAndPort, port, err := findPort(routeWithoutPath) 429 | if err != nil { 430 | *errs = append(*errs, err) 431 | } 432 | hostname, domain, err := findDomain(cfDomains, routeWithoutPathAndPort) 433 | if err != nil { 434 | *errs = append(*errs, err) 435 | } 436 | manifestRoutes = append(manifestRoutes, plugin_models.GetApp_RouteSummary{ 437 | 438 | // HTTP routes include a domain, an optional hostname, and an optional context path 439 | Host: hostname, 440 | Domain: domain, 441 | Path: path, 442 | Port: port, 443 | }) 444 | } else { 445 | *errs = append(*errs, fmt.Errorf("each route in 'routes' must have a 'route' property")) 446 | } 447 | } 448 | 449 | return manifestRoutes 450 | } 451 | 452 | func findPath(routeName string) (string, string) { 453 | routeSlice := strings.Split(routeName, "/") 454 | return routeSlice[0], strings.Join(routeSlice[1:], "/") 455 | } 456 | 457 | func findPort(routeName string) (string, int, error) { 458 | var err error 459 | routeSlice := strings.Split(routeName, ":") 460 | port := 0 461 | if len(routeSlice) == 2 { 462 | port, err = strconv.Atoi(routeSlice[1]) 463 | if err != nil { 464 | return "", 0, errors.New(fmt.Sprintf("Invalid port for route %s", routeName)) 465 | } 466 | } 467 | return routeSlice[0], port, nil 468 | } 469 | 470 | func findDomain(cfDomains CfDomains, routeName string) (string, plugin_models.GetApp_DomainFields, error) { 471 | host, domain := decomposeRoute(cfDomains.SharedDomains, routeName) 472 | if domain == nil { 473 | host, domain = decomposeRoute(cfDomains.PrivateDomains, routeName) 474 | } 475 | if domain == nil { 476 | 477 | return "", plugin_models.GetApp_DomainFields{}, fmt.Errorf( 478 | "The route %s did not match any existing domains", 479 | routeName, 480 | ) 481 | } 482 | return host, *domain, nil 483 | } 484 | 485 | func decomposeRoute(allowedDomains []string, routeName string) (string, *plugin_models.GetApp_DomainFields) { 486 | 487 | var testDomain = func(routeName string) (*plugin_models.GetApp_DomainFields, bool) { 488 | 489 | domain := &plugin_models.GetApp_DomainFields{} 490 | for _, possibleDomain := range allowedDomains { 491 | if possibleDomain == routeName { 492 | domain.Name = routeName 493 | return domain, true 494 | } 495 | } 496 | return domain, false 497 | } 498 | 499 | domain, found := testDomain(routeName) 500 | if found { 501 | return "", domain 502 | } 503 | 504 | routeParts := strings.Split(routeName, ".") 505 | domain, found = testDomain(strings.Join(routeParts[1:], ".")) 506 | if found { 507 | return routeParts[0], domain 508 | } 509 | 510 | return "", nil 511 | } 512 | 513 | func (manifest *Manifest) GetAppParams(appName string, cfDomains CfDomains) *plugin_models.GetAppModel { 514 | var err error 515 | apps, err := manifest.Applications(cfDomains) 516 | if err != nil { 517 | fmt.Println(err) 518 | return nil 519 | } 520 | 521 | for index, app := range apps { 522 | if isHostOrDomainEmpty(app) { 523 | continue 524 | } 525 | if app.Name != "" && app.Name != appName { 526 | continue 527 | } 528 | return &apps[index] 529 | } 530 | 531 | return nil 532 | } 533 | 534 | func isHostOrDomainEmpty(app plugin_models.GetAppModel) bool { 535 | for _, route := range app.Routes { 536 | if route.Host != "" || route.Domain.Name != "" { 537 | return false 538 | } 539 | } 540 | return true 541 | } 542 | -------------------------------------------------------------------------------- /blue_green_deploy_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "strings" 7 | 8 | "code.cloudfoundry.org/cli/plugin/models" 9 | "code.cloudfoundry.org/cli/plugin/pluginfakes" 10 | "fmt" 11 | . "github.com/bluemixgaragelondon/cf-blue-green-deploy" 12 | . "github.com/onsi/ginkgo" 13 | . "github.com/onsi/gomega" 14 | ) 15 | 16 | var _ = Describe("BlueGreenDeploy", func() { 17 | var ( 18 | bgdExitsWithErrors []error 19 | bgdOut *bytes.Buffer 20 | connection *pluginfakes.FakeCliConnection 21 | p BlueGreenDeploy 22 | testErrorFunc func(message string, err error) 23 | ) 24 | 25 | BeforeEach(func() { 26 | bgdExitsWithErrors = []error{} 27 | testErrorFunc = func(message string, err error) { 28 | bgdExitsWithErrors = append(bgdExitsWithErrors, err) 29 | } 30 | bgdOut = &bytes.Buffer{} 31 | 32 | connection = &pluginfakes.FakeCliConnection{} 33 | p = BlueGreenDeploy{Connection: connection, ErrorFunc: testErrorFunc, Out: bgdOut} 34 | }) 35 | 36 | Describe("maps routes", func() { 37 | var ( 38 | manifestApp plugin_models.GetAppModel 39 | ) 40 | 41 | BeforeEach(func() { 42 | manifestApp = plugin_models.GetAppModel{ 43 | Name: "new", 44 | Routes: []plugin_models.GetApp_RouteSummary{ 45 | {Host: "host", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 46 | {Host: "host", Domain: plugin_models.GetApp_DomainFields{Name: "example.net"}}, 47 | }, 48 | } 49 | }) 50 | 51 | It("maps all", func() { 52 | p.MapRoutesToApp(manifestApp.Name, manifestApp.Routes...) 53 | 54 | cfCommands := getAllCfCommands(connection) 55 | 56 | Expect(cfCommands).To(Equal([]string{ 57 | "map-route new example.com -n host", 58 | "map-route new example.net -n host", 59 | })) 60 | }) 61 | }) 62 | 63 | Describe("remove routes from old app", func() { 64 | var ( 65 | oldApp plugin_models.GetAppModel 66 | ) 67 | 68 | BeforeEach(func() { 69 | oldApp = plugin_models.GetAppModel{ 70 | Name: "old", 71 | Routes: []plugin_models.GetApp_RouteSummary{ 72 | {Host: "live", Domain: plugin_models.GetApp_DomainFields{Name: "mybluemix.net"}}, 73 | {Host: "live", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 74 | }, 75 | } 76 | }) 77 | 78 | It("unmaps all routes from the old app", func() { 79 | p.UnmapRoutesFromApp(oldApp.Name, oldApp.Routes...) 80 | 81 | cfCommands := getAllCfCommands(connection) 82 | 83 | Expect(cfCommands).To(Equal([]string{ 84 | "unmap-route old mybluemix.net -n live", 85 | "unmap-route old example.com -n live", 86 | })) 87 | }) 88 | 89 | It("unmaps routes from old app with paths", func() { 90 | oldApp = plugin_models.GetAppModel{ 91 | Name: "old", 92 | Routes: []plugin_models.GetApp_RouteSummary{ 93 | {Host: "live", Domain: plugin_models.GetApp_DomainFields{Name: "mybluemix.net"}, Path: "my/context/path1"}, 94 | {Host: "live", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}, Path: "my/context/path2"}, 95 | }, 96 | } 97 | p.UnmapRoutesFromApp(oldApp.Name, oldApp.Routes...) 98 | 99 | cfCommands := getAllCfCommands(connection) 100 | 101 | Expect(cfCommands).To(Equal([]string{ 102 | "unmap-route old mybluemix.net -n live --path my/context/path1", 103 | "unmap-route old example.com -n live --path my/context/path2", 104 | })) 105 | }) 106 | }) 107 | 108 | Describe("checks ssh enablement", func() { 109 | Context("when ssh support is disabled", func() { 110 | BeforeEach(func() { 111 | connection.CliCommandStub = func(args ...string) ([]string, error) { 112 | return []string{fmt.Sprintf("ssh support is disabled for '%s'", args[0])}, nil 113 | } 114 | }) 115 | 116 | It("returns false", func() { 117 | result := p.CheckSshEnablement("test-app") 118 | 119 | Expect(result).To(BeFalse()) 120 | cfCommands := getAllCfCommands(connection) 121 | 122 | Expect(cfCommands).To(Equal([]string{ 123 | "ssh-enabled test-app", 124 | })) 125 | }) 126 | }) 127 | 128 | Context("when ssh support is enabled", func() { 129 | BeforeEach(func() { 130 | connection.CliCommandStub = func(args ...string) ([]string, error) { 131 | return []string{fmt.Sprintf("ssh support is enabled for '%s'", args[0])}, nil 132 | } 133 | }) 134 | 135 | It("returns true", func() { 136 | result := p.CheckSshEnablement("test-app") 137 | 138 | Expect(result).To(BeTrue()) 139 | cfCommands := getAllCfCommands(connection) 140 | 141 | Expect(cfCommands).To(Equal([]string{ 142 | "ssh-enabled test-app", 143 | })) 144 | }) 145 | }) 146 | 147 | Context("when cf cli errors", func() { 148 | BeforeEach(func() { 149 | connection.CliCommandStub = func(args ...string) ([]string, error) { 150 | return nil, errors.New("failed to check ssh enablement status") 151 | } 152 | }) 153 | 154 | It("it reports the error", func() { 155 | p.CheckSshEnablement("test-app") 156 | Expect(bgdExitsWithErrors[0]).To(MatchError("failed to check ssh enablement status")) 157 | }) 158 | }) 159 | }) 160 | 161 | Describe("set ssh access", func() { 162 | Context("when it just works", func() { 163 | It("enables ssh", func() { 164 | p.SetSshAccess("test-app", true) 165 | 166 | cfCommands := getAllCfCommands(connection) 167 | 168 | Expect(cfCommands).To(Equal([]string{ 169 | "enable-ssh test-app", 170 | })) 171 | }) 172 | It("disables ssh", func() { 173 | p.SetSshAccess("test-app", false) 174 | 175 | cfCommands := getAllCfCommands(connection) 176 | 177 | Expect(cfCommands).To(Equal([]string{ 178 | "disable-ssh test-app", 179 | })) 180 | }) 181 | }) 182 | Context("when cf enable-ssh errors", func() { 183 | BeforeEach(func() { 184 | connection.CliCommandStub = func(args ...string) ([]string, error) { 185 | return nil, errors.New("failed to enable ssh") 186 | } 187 | }) 188 | 189 | It("it reports the error", func() { 190 | p.SetSshAccess("test-app", true) 191 | Expect(bgdExitsWithErrors[0]).To(MatchError("failed to enable ssh")) 192 | }) 193 | }) 194 | Context("when cf disable-ssh errors", func() { 195 | BeforeEach(func() { 196 | connection.CliCommandStub = func(args ...string) ([]string, error) { 197 | return nil, errors.New("failed to disable ssh") 198 | } 199 | }) 200 | 201 | It("it reports the error", func() { 202 | p.SetSshAccess("test-app", false) 203 | Expect(bgdExitsWithErrors[0]).To(MatchError("failed to disable ssh")) 204 | }) 205 | }) 206 | }) 207 | 208 | Describe("renaming an app", func() { 209 | var app string 210 | 211 | BeforeEach(func() { 212 | app = "foo" 213 | }) 214 | 215 | It("renames the app", func() { 216 | p.RenameApp(app, "bar") 217 | cfCommands := getAllCfCommands(connection) 218 | 219 | Expect(cfCommands).To(ContainElement( 220 | "rename foo bar", 221 | )) 222 | }) 223 | 224 | Context("when renaming the app fails", func() { 225 | It("calls the error callback", func() { 226 | connection.CliCommandStub = func(args ...string) ([]string, error) { 227 | return nil, errors.New("failed to rename app") 228 | } 229 | p.RenameApp(app, "bar") 230 | 231 | Expect(bgdExitsWithErrors[0]).To(MatchError("failed to rename app")) 232 | }) 233 | }) 234 | }) 235 | 236 | Describe("delete old apps", func() { 237 | var ( 238 | apps []plugin_models.GetAppsModel 239 | ) 240 | 241 | Context("with live and old apps", func() { 242 | BeforeEach(func() { 243 | apps = []plugin_models.GetAppsModel{ 244 | {Name: "app-name-old"}, 245 | {Name: "app-name"}, 246 | } 247 | connection.GetAppsReturns(apps, nil) 248 | }) 249 | 250 | It("only deletes the old apps", func() { 251 | p.DeleteAllAppsExceptLiveApp("app-name") 252 | cfCommands := getAllCfCommands(connection) 253 | 254 | Expect(cfCommands).To(Equal([]string{ 255 | "delete app-name-old -f -r", 256 | })) 257 | }) 258 | 259 | Context("when the deletion of an app fails", func() { 260 | BeforeEach(func() { 261 | connection.CliCommandStub = func(args ...string) ([]string, error) { 262 | return nil, errors.New("failed to delete app") 263 | } 264 | }) 265 | 266 | It("returns an error", func() { 267 | p.DeleteAllAppsExceptLiveApp("app-name") 268 | Expect(bgdExitsWithErrors[0]).To(HaveOccurred()) 269 | }) 270 | }) 271 | }) 272 | 273 | Context("with live and failed apps", func() { 274 | BeforeEach(func() { 275 | apps = []plugin_models.GetAppsModel{ 276 | {Name: "app-name-failed"}, 277 | {Name: "app-name"}, 278 | } 279 | connection.GetAppsReturns(apps, nil) 280 | }) 281 | 282 | It("only deletes the failed apps", func() { 283 | p.DeleteAllAppsExceptLiveApp("app-name") 284 | cfCommands := getAllCfCommands(connection) 285 | 286 | Expect(cfCommands).To(Equal([]string{ 287 | "delete app-name-failed -f -r", 288 | })) 289 | }) 290 | }) 291 | 292 | Context("with live and new apps", func() { 293 | BeforeEach(func() { 294 | apps = []plugin_models.GetAppsModel{ 295 | {Name: "app-name-new"}, 296 | {Name: "app-name"}, 297 | } 298 | connection.GetAppsReturns(apps, nil) 299 | }) 300 | 301 | It("only deletes the new apps", func() { 302 | p.DeleteAllAppsExceptLiveApp("app-name") 303 | cfCommands := getAllCfCommands(connection) 304 | 305 | Expect(cfCommands).To(Equal([]string{ 306 | "delete app-name-new -f -r", 307 | })) 308 | }) 309 | }) 310 | 311 | Context("when there is no old version deployed", func() { 312 | BeforeEach(func() { 313 | apps = []plugin_models.GetAppsModel{ 314 | {Name: "app-name"}, 315 | } 316 | connection.GetAppsReturns(apps, nil) 317 | }) 318 | 319 | It("succeeds", func() { 320 | p.DeleteAllAppsExceptLiveApp("app-name") 321 | Expect(bgdExitsWithErrors).To(HaveLen(0)) 322 | }) 323 | 324 | It("deletes nothing", func() { 325 | p.DeleteAllAppsExceptLiveApp("app-name") 326 | Expect(connection.CliCommandCallCount()).To(Equal(0)) 327 | }) 328 | }) 329 | }) 330 | 331 | Describe("delete old apps (but not failed ones)", func() { 332 | var ( 333 | apps []plugin_models.GetAppsModel 334 | ) 335 | 336 | Context("with live and old apps", func() { 337 | BeforeEach(func() { 338 | apps = []plugin_models.GetAppsModel{ 339 | {Name: "app-name-old"}, 340 | {Name: "app-name"}, 341 | } 342 | connection.GetAppsReturns(apps, nil) 343 | }) 344 | 345 | It("only deletes the old apps", func() { 346 | p.DeleteAllAppsExceptLiveAndFailedApp("app-name") 347 | cfCommands := getAllCfCommands(connection) 348 | 349 | Expect(cfCommands).To(Equal([]string{ 350 | "delete app-name-old -f -r", 351 | })) 352 | }) 353 | 354 | Context("when the deletion of an app fails", func() { 355 | BeforeEach(func() { 356 | connection.CliCommandStub = func(args ...string) ([]string, error) { 357 | return nil, errors.New("failed to delete app") 358 | } 359 | }) 360 | 361 | It("returns an error", func() { 362 | p.DeleteAllAppsExceptLiveAndFailedApp("app-name") 363 | Expect(bgdExitsWithErrors[0]).To(HaveOccurred()) 364 | }) 365 | }) 366 | }) 367 | 368 | Context("with live and failed apps", func() { 369 | BeforeEach(func() { 370 | apps = []plugin_models.GetAppsModel{ 371 | {Name: "app-name-failed"}, 372 | {Name: "app-name"}, 373 | } 374 | connection.GetAppsReturns(apps, nil) 375 | }) 376 | 377 | It("succeeds", func() { 378 | p.DeleteAllAppsExceptLiveAndFailedApp("app-name") 379 | Expect(bgdExitsWithErrors).To(HaveLen(0)) 380 | }) 381 | 382 | It("deletes nothing", func() { 383 | p.DeleteAllAppsExceptLiveAndFailedApp("app-name") 384 | Expect(connection.CliCommandCallCount()).To(Equal(0)) 385 | }) 386 | }) 387 | 388 | Context("with live and new apps", func() { 389 | BeforeEach(func() { 390 | apps = []plugin_models.GetAppsModel{ 391 | {Name: "app-name-new"}, 392 | {Name: "app-name"}, 393 | } 394 | connection.GetAppsReturns(apps, nil) 395 | }) 396 | 397 | It("only deletes the new apps", func() { 398 | p.DeleteAllAppsExceptLiveAndFailedApp("app-name") 399 | cfCommands := getAllCfCommands(connection) 400 | 401 | Expect(cfCommands).To(Equal([]string{ 402 | "delete app-name-new -f -r", 403 | })) 404 | }) 405 | }) 406 | 407 | Context("when there is no old version deployed", func() { 408 | BeforeEach(func() { 409 | apps = []plugin_models.GetAppsModel{ 410 | {Name: "app-name"}, 411 | } 412 | connection.GetAppsReturns(apps, nil) 413 | }) 414 | 415 | It("succeeds", func() { 416 | p.DeleteAllAppsExceptLiveAndFailedApp("app-name") 417 | Expect(bgdExitsWithErrors).To(HaveLen(0)) 418 | }) 419 | 420 | It("deletes nothing", func() { 421 | p.DeleteAllAppsExceptLiveAndFailedApp("app-name") 422 | Expect(connection.CliCommandCallCount()).To(Equal(0)) 423 | }) 424 | }) 425 | }) 426 | 427 | Describe("deleting apps", func() { 428 | Context("when there is an old version deployed", func() { 429 | apps := []plugin_models.GetAppsModel{ 430 | {Name: "app-name-old"}, 431 | {Name: "app-name-old"}, 432 | } 433 | 434 | It("deletes the apps", func() { 435 | p.DeleteAppVersions(apps) 436 | cfCommands := getAllCfCommands(connection) 437 | 438 | Expect(cfCommands).To(Equal([]string{ 439 | "delete app-name-old -f -r", 440 | "delete app-name-old -f -r", 441 | })) 442 | }) 443 | 444 | Context("when the deletion of an app fails", func() { 445 | BeforeEach(func() { 446 | connection.CliCommandStub = func(args ...string) ([]string, error) { 447 | return nil, errors.New("failed to delete app") 448 | } 449 | }) 450 | 451 | It("returns an error", func() { 452 | p.DeleteAppVersions(apps) 453 | Expect(bgdExitsWithErrors[0]).To(HaveOccurred()) 454 | }) 455 | }) 456 | }) 457 | 458 | Context("when there is no old version deployed", func() { 459 | apps := []plugin_models.GetAppsModel{} 460 | 461 | It("succeeds", func() { 462 | p.DeleteAppVersions(apps) 463 | Expect(bgdExitsWithErrors).To(HaveLen(0)) 464 | }) 465 | 466 | It("deletes nothing", func() { 467 | p.DeleteAppVersions(apps) 468 | Expect(connection.CliCommandCallCount()).To(Equal(0)) 469 | }) 470 | }) 471 | }) 472 | 473 | Describe("getting the scale parameters", func() { 474 | Context("for a running app", func() { 475 | appName := "existing app" 476 | var instanceCount int = 3 477 | var memory int64 = 9001 478 | var diskQuota int64 = 100 479 | BeforeEach(func() { 480 | appModel := plugin_models.GetAppModel{ 481 | InstanceCount: instanceCount, 482 | Memory: memory, 483 | DiskQuota: diskQuota, 484 | } 485 | connection.GetAppReturns(appModel, nil) 486 | }) 487 | It("reads the app data and returns the scale parameters", func() { 488 | scaleParameters, _ := p.GetScaleParameters(appName) 489 | Expect(scaleParameters.InstanceCount).To(Equal(instanceCount)) 490 | Expect(scaleParameters.Memory).To(Equal(memory)) 491 | Expect(scaleParameters.DiskQuota).To(Equal(diskQuota)) 492 | }) 493 | }) 494 | Context("for an app that does not exist", func() { 495 | appName := "invalid app" 496 | BeforeEach(func() { 497 | appModel := plugin_models.GetAppModel{} 498 | connection.GetAppReturns(appModel, errors.New("App was not found")) 499 | }) 500 | It("returns an empty struct and an error value", func() { 501 | scaleParameters, error := p.GetScaleParameters(appName) 502 | Expect(error).ToNot(Equal(nil)) 503 | Expect(scaleParameters.InstanceCount).To(Equal(0)) 504 | Expect(scaleParameters.Memory).To(Equal(int64(0))) 505 | Expect(scaleParameters.DiskQuota).To(Equal(int64(0))) 506 | }) 507 | }) 508 | }) 509 | 510 | Describe("pushing a new app", func() { 511 | newApp := "app-name-new" 512 | newRoute := plugin_models.GetApp_RouteSummary{Host: newApp, Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}} 513 | scaleParameters := ScaleParameters{} 514 | 515 | It("pushes an app with new appended to its name", func() { 516 | p.PushNewApp(newApp, newRoute, "", scaleParameters) 517 | 518 | Expect(strings.Join(connection.CliCommandArgsForCall(0), " ")). 519 | To(MatchRegexp(`^push app-name-new`)) 520 | }) 521 | 522 | It("uses the generated name for the route", func() { 523 | p.PushNewApp(newApp, newRoute, "", scaleParameters) 524 | 525 | Expect(strings.Join(connection.CliCommandArgsForCall(0), " ")). 526 | To(MatchRegexp(`-n app-name-new`)) 527 | }) 528 | 529 | It("pushes with the default cf domain", func() { 530 | p.PushNewApp(newApp, newRoute, "", scaleParameters) 531 | 532 | Expect(strings.Join(connection.CliCommandArgsForCall(0), " ")). 533 | To(MatchRegexp(`-d example.com`)) 534 | }) 535 | 536 | It("pushes with the specified manifest, if present", func() { 537 | manifestPath := "./manifest-tst.yml" 538 | p.PushNewApp(newApp, newRoute, manifestPath, scaleParameters) 539 | 540 | Expect(strings.Join(connection.CliCommandArgsForCall(0), " ")). 541 | To(MatchRegexp(`-f ./manifest-tst.yml`)) 542 | }) 543 | 544 | It("pushes without a manifest arg, if no manifest in deployer", func() { 545 | p.PushNewApp(newApp, newRoute, "", scaleParameters) 546 | 547 | Expect(strings.Join(connection.CliCommandArgsForCall(0), " ")). 548 | To(Not(MatchRegexp(`-f `))) 549 | }) 550 | 551 | It("pushes using the scale values of the old app", func() { 552 | liveAppModel := plugin_models.GetAppModel{ 553 | Memory: int64(32), 554 | DiskQuota: int64(700), 555 | InstanceCount: 27, 556 | } 557 | connection.GetAppReturns(liveAppModel, nil) 558 | 559 | p.PushNewApp(newApp, newRoute, "", ScaleParameters{}) 560 | 561 | commandString := strings.Join(connection.CliCommandArgsForCall(0), " ") 562 | Expect(commandString).To(MatchRegexp(`-m 32M`)) 563 | Expect(commandString).To(MatchRegexp(`-k 700M`)) 564 | Expect(commandString).To(MatchRegexp(`-i 27`)) 565 | }) 566 | 567 | It("uses the manifest memory field if there is a live app running", func() { 568 | liveAppModel := plugin_models.GetAppModel{ 569 | Memory: int64(16), 570 | DiskQuota: int64(500), 571 | InstanceCount: 6, 572 | } 573 | connection.GetAppReturns(liveAppModel, nil) 574 | manifestScaleParameters := ScaleParameters{ 575 | Memory: int64(32), 576 | } 577 | p.PushNewApp(newApp, newRoute, "", manifestScaleParameters) 578 | commandString := strings.Join(connection.CliCommandArgsForCall(0), " ") 579 | Expect(commandString).To(MatchRegexp(`-m 32M`)) 580 | Expect(commandString).To(MatchRegexp(`-k 500M`)) 581 | Expect(commandString).To(MatchRegexp(`-i 6`)) 582 | }) 583 | 584 | Context("when some scale parameter values are zero", func() { 585 | It("pushes using only the defined parameters", func() { 586 | scaleParameters = ScaleParameters{ 587 | InstanceCount: 0, 588 | Memory: 32, 589 | DiskQuota: 0, 590 | } 591 | p.PushNewApp(newApp, newRoute, "", scaleParameters) 592 | 593 | commandString := strings.Join(connection.CliCommandArgsForCall(0), " ") 594 | Expect(commandString).To(MatchRegexp(`-m`)) 595 | Expect(commandString).ToNot(MatchRegexp(`-k`)) 596 | Expect(commandString).ToNot(MatchRegexp(`-i`)) 597 | }) 598 | }) 599 | 600 | Context("when the push fails", func() { 601 | BeforeEach(func() { 602 | connection.CliCommandStub = func(args ...string) ([]string, error) { 603 | return nil, errors.New("failed to push app") 604 | } 605 | }) 606 | 607 | It("returns an error", func() { 608 | p.PushNewApp(newApp, newRoute, "", scaleParameters) 609 | 610 | Expect(bgdExitsWithErrors[0]).To(MatchError("failed to push app")) 611 | }) 612 | }) 613 | }) 614 | 615 | Describe("live app", func() { 616 | liveApp := plugin_models.GetAppModel{Name: "app-name"} 617 | 618 | Context("with live and old apps", func() { 619 | It("returns the live app", func() { 620 | connection.GetAppReturns(liveApp, nil) 621 | 622 | name, _ := p.LiveApp("app-name") 623 | Expect(name).To(Equal(liveApp.Name)) 624 | }) 625 | }) 626 | 627 | Context("with no apps", func() { 628 | It("returns an empty app name", func() { 629 | connection.GetAppReturns(plugin_models.GetAppModel{}, errors.New("an error for no apps")) 630 | 631 | name, _ := p.LiveApp("app-name") 632 | Expect(name).To(BeEmpty()) 633 | }) 634 | }) 635 | }) 636 | 637 | Describe("app filter", func() { 638 | Context("when there are 2 old versions and 1 non-old version", func() { 639 | var ( 640 | appList []plugin_models.GetAppsModel 641 | oldApps []plugin_models.GetAppsModel 642 | ) 643 | 644 | BeforeEach(func() { 645 | appList = []plugin_models.GetAppsModel{ 646 | {Name: "foo-old"}, 647 | {Name: "foo-old"}, 648 | {Name: "foo"}, 649 | {Name: "bar-foo-old"}, 650 | {Name: "foo-older"}, 651 | } 652 | oldApps = p.GetOldApps("foo", appList) 653 | }) 654 | 655 | Describe("old app list", func() { 656 | It("returns all apps that have the same name, with a valid timestamp and -old suffix", func() { 657 | Expect(oldApps).To(ContainElement(appList[0])) 658 | Expect(oldApps).To(ContainElement(appList[1])) 659 | }) 660 | 661 | It("doesn't return any apps that don't have a -old suffix", func() { 662 | Expect(oldApps).ToNot(ContainElement(appList[2])) 663 | }) 664 | 665 | It("doesn't return elements that have an additional prefix before the app name", func() { 666 | Expect(oldApps).ToNot(ContainElement(appList[3])) 667 | }) 668 | 669 | It("doesn't return elements that have an additional suffix after -old", func() { 670 | Expect(oldApps).ToNot(ContainElement(appList[4])) 671 | }) 672 | }) 673 | }) 674 | }) 675 | 676 | Describe("smoke test runner", func() { 677 | It("returns stdout", func() { 678 | _ = p.RunSmokeTests("test/support/smoke-test-script", "app.mybluemix.net") 679 | Expect(bgdOut.String()).To(ContainSubstring("STDOUT")) 680 | }) 681 | 682 | It("returns stderr", func() { 683 | _ = p.RunSmokeTests("test/support/smoke-test-script", "app.mybluemix.net") 684 | Expect(bgdOut.String()).To(ContainSubstring("STDERR")) 685 | }) 686 | 687 | It("passes app FQDN as first argument", func() { 688 | _ = p.RunSmokeTests("test/support/smoke-test-script", "app.mybluemix.net") 689 | Expect(bgdOut.String()).To(ContainSubstring("App FQDN is: app.mybluemix.net")) 690 | }) 691 | 692 | Context("when script doesn't exist", func() { 693 | It("fails with useful error", func() { 694 | _ = p.RunSmokeTests("inexistent-smoke-test-script", "app.mybluemix.net") 695 | Expect(bgdExitsWithErrors[0].Error()).To(ContainSubstring("executable file not found")) 696 | }) 697 | }) 698 | 699 | Context("when script isn't executable", func() { 700 | It("fails with useful error", func() { 701 | _ = p.RunSmokeTests("test/support/nonexec-smoke-test-script", "app.mybluemix.net") 702 | Expect(bgdExitsWithErrors[0].Error()).To(ContainSubstring("permission denied")) 703 | }) 704 | }) 705 | 706 | Context("when script fails", func() { 707 | var passSmokeTest bool 708 | 709 | BeforeEach(func() { 710 | passSmokeTest = p.RunSmokeTests("test/support/smoke-test-script", "FORCE-SMOKE-TEST-FAILURE") 711 | }) 712 | 713 | It("returns false", func() { 714 | Expect(passSmokeTest).To(Equal(false)) 715 | }) 716 | 717 | It("doesn't fail", func() { 718 | Expect(bgdExitsWithErrors).To(HaveLen(0)) 719 | }) 720 | }) 721 | }) 722 | 723 | }) 724 | 725 | func getAllCfCommands(connection *pluginfakes.FakeCliConnection) (commands []string) { 726 | commands = []string{} 727 | for i := 0; i < connection.CliCommandCallCount(); i++ { 728 | args := connection.CliCommandArgsForCall(i) 729 | commands = append(commands, strings.Join(args, " ")) 730 | } 731 | return 732 | } 733 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "code.cloudfoundry.org/cli/plugin" 8 | "code.cloudfoundry.org/cli/plugin/models" 9 | "code.cloudfoundry.org/cli/plugin/pluginfakes" 10 | . "github.com/bluemixgaragelondon/cf-blue-green-deploy" 11 | "github.com/bluemixgaragelondon/cf-blue-green-deploy/manifest" 12 | "github.com/bluemixgaragelondon/cf-blue-green-deploy/manifest/fakes" 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | "strings" 16 | ) 17 | 18 | var _ = Describe("BGD Plugin", func() { 19 | 20 | Describe("blue green flow", func() { 21 | Context("when there is a previous live app", func() { 22 | It("calls methods in correct order", func() { 23 | b := &BlueGreenDeployFake{liveApp: &plugin_models.GetAppModel{Name: "app-name-live"}, appSshEnabled: false} 24 | p := CfPlugin{ 25 | Deployer: b, 26 | } 27 | 28 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, &fakes.FakeManifestReader{}, NewArgs([]string{"bgd", "app-name"})) 29 | 30 | Expect(b.flow).To(Equal([]string{ 31 | "delete old apps", 32 | "get current live app", 33 | "push app-name-new", 34 | "check ssh enablement for 'app-name'", 35 | "set ssh enablement for 'app-name-new' to 'false'", 36 | "unmap 1 routes from app-name-new", 37 | "delete 1 routes", 38 | "mapped 1 routes", 39 | "rename app-name-live to app-name-old", 40 | "rename app-name-new to app-name", 41 | "unmap 0 routes from app-name-old", 42 | })) 43 | }) 44 | 45 | Context("and we want to delete the old app instances", func() { 46 | It("calls 'delete old apps'", func() { 47 | b := &BlueGreenDeployFake{liveApp: &plugin_models.GetAppModel{Name: "app-name-live"}, appSshEnabled: false} 48 | p := CfPlugin{ 49 | Deployer: b, 50 | } 51 | 52 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, &fakes.FakeManifestReader{}, NewArgs([]string{"bgd", "app-name", "--delete-old-apps"})) 53 | 54 | Expect(b.flow).To(Equal([]string{ 55 | "delete old apps", 56 | "get current live app", 57 | "push app-name-new", 58 | "check ssh enablement for 'app-name'", 59 | "set ssh enablement for 'app-name-new' to 'false'", 60 | "unmap 1 routes from app-name-new", 61 | "delete 1 routes", 62 | "mapped 1 routes", 63 | "rename app-name-live to app-name-old", 64 | "rename app-name-new to app-name", 65 | "unmap 0 routes from app-name-old", 66 | "delete old apps except failed ones", 67 | })) 68 | }) 69 | }) 70 | 71 | Context("with an existing live route", func() { 72 | It("maps the live app routes to the new app", func() { 73 | 74 | liveAppRoutes := []plugin_models.GetApp_RouteSummary{ 75 | {Host: "host1", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 76 | {Host: "host2", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 77 | } 78 | 79 | b := &BlueGreenDeployFake{ 80 | liveApp: &plugin_models.GetAppModel{Name: "app-name-live", 81 | Routes: liveAppRoutes}, 82 | } 83 | p := CfPlugin{ 84 | Deployer: b, 85 | } 86 | 87 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, &fakes.FakeManifestReader{}, NewArgs([]string{"bgd", "app-name"})) 88 | 89 | deletedTempRoute := plugin_models.GetApp_RouteSummary{Host: "app-name-new", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}} 90 | Expect(b.deletedRoutes).To(ConsistOf(deletedTempRoute)) 91 | Expect(b.mappedRoutes).To(ConsistOf(liveAppRoutes)) 92 | }) 93 | }) 94 | 95 | Context("with an existing live route and manifest", func() { 96 | It("maps both manifest & live app routes", func() { 97 | liveAppRoutes := []plugin_models.GetApp_RouteSummary{ 98 | {Host: "host1", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 99 | {Host: "host2", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 100 | } 101 | 102 | b := &BlueGreenDeployFake{ 103 | liveApp: &plugin_models.GetAppModel{Name: "app-name-live", 104 | Routes: liveAppRoutes}, 105 | } 106 | p := CfPlugin{ 107 | Deployer: b, 108 | } 109 | repo := &fakes.FakeManifestReader{Yaml: `--- 110 | name: app-name 111 | hosts: 112 | - man1 113 | domains: 114 | - example.com 115 | `} 116 | 117 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, repo, NewArgs([]string{"bgd", "app-name"})) 118 | 119 | expectedAppRoutes := append(liveAppRoutes, plugin_models.GetApp_RouteSummary{Host: "man1", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}) 120 | 121 | Expect(b.mappedRoutes).To(ConsistOf(expectedAppRoutes)) 122 | }) 123 | 124 | It("maps unique routes", func() { 125 | liveAppRoutes := []plugin_models.GetApp_RouteSummary{ 126 | {Host: "host1", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 127 | {Host: "host2", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 128 | } 129 | 130 | b := &BlueGreenDeployFake{ 131 | liveApp: &plugin_models.GetAppModel{Name: "app-name-live", 132 | Routes: liveAppRoutes}, 133 | } 134 | p := CfPlugin{ 135 | Deployer: b, 136 | } 137 | repo := &fakes.FakeManifestReader{Yaml: `--- 138 | name: app-name 139 | hosts: 140 | - man1 141 | - host1 142 | - host2 143 | domains: 144 | - example.com 145 | `} 146 | 147 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, repo, NewArgs([]string{"bgd", "app-name"})) 148 | 149 | expectedAppRoutes := append(liveAppRoutes, plugin_models.GetApp_RouteSummary{Host: "man1", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}) 150 | 151 | Expect(b.mappedRoutes).To(ConsistOf(expectedAppRoutes)) 152 | 153 | }) 154 | }) 155 | }) 156 | 157 | Context("when there is no previous live app", func() { 158 | It("calls methods in correct order", func() { 159 | b := &BlueGreenDeployFake{liveApp: nil} 160 | p := CfPlugin{ 161 | Deployer: b, 162 | } 163 | 164 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, &fakes.FakeManifestReader{}, NewArgs([]string{"bgd", "app-name"})) 165 | 166 | Expect(b.flow).To(Equal([]string{ 167 | "delete old apps", 168 | "get current live app", 169 | "push app-name-new", 170 | "unmap 1 routes from app-name-new", 171 | "delete 1 routes", 172 | "mapped 1 routes", 173 | "rename app-name-new to app-name", 174 | })) 175 | }) 176 | }) 177 | 178 | Context("when app has manifest", func() { 179 | Context("when manifest uses hosts and domains", func() { 180 | It("maps manifest routes", func() { 181 | b := &BlueGreenDeployFake{liveApp: nil} 182 | p := CfPlugin{ 183 | Deployer: b, 184 | } 185 | repo := &fakes.FakeManifestReader{Yaml: `--- 186 | name: app-name 187 | hosts: 188 | - host1 189 | - host2 190 | domains: 191 | - specific.com 192 | - specific.net 193 | `} 194 | 195 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, repo, NewArgs([]string{"bgd", "app-name"})) 196 | Expect(b.flow).To(Equal([]string{ 197 | "delete old apps", 198 | "get current live app", 199 | "push app-name-new", 200 | "unmap 1 routes from app-name-new", 201 | "delete 1 routes", 202 | "mapped 4 routes", 203 | "rename app-name-new to app-name", 204 | })) 205 | 206 | deletedTempRoute := plugin_models.GetApp_RouteSummary{Host: "app-name-new", Domain: plugin_models.GetApp_DomainFields{Name: "specific.com"}} 207 | Expect(b.deletedRoutes).To(ConsistOf(deletedTempRoute)) 208 | 209 | expectedRoutes := []plugin_models.GetApp_RouteSummary{ 210 | plugin_models.GetApp_RouteSummary{Host: "host1", Domain: plugin_models.GetApp_DomainFields{Name: "specific.com"}}, 211 | plugin_models.GetApp_RouteSummary{Host: "host2", Domain: plugin_models.GetApp_DomainFields{Name: "specific.com"}}, 212 | plugin_models.GetApp_RouteSummary{Host: "host1", Domain: plugin_models.GetApp_DomainFields{Name: "specific.net"}}, 213 | plugin_models.GetApp_RouteSummary{Host: "host2", Domain: plugin_models.GetApp_DomainFields{Name: "specific.net"}}, 214 | } 215 | 216 | Expect(len(b.mappedRoutes)).To(Equal(4)) 217 | 218 | Expect(b.mappedRoutes).To(ConsistOf(expectedRoutes)) 219 | }) 220 | }) 221 | Context("when manifest uses routes", func() { 222 | It("maps manifest routes", func() { 223 | b := &BlueGreenDeployFake{liveApp: nil} 224 | p := CfPlugin{ 225 | Deployer: b, 226 | } 227 | repo := &fakes.FakeManifestReader{Yaml: `--- 228 | name: app-name 229 | routes: 230 | - route: host1.something.com 231 | - route: host2.mine.com 232 | - route: host3.common.com 233 | `} 234 | 235 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com", SharedDomains: []string{"common.com"}, PrivateDomains: []string{"mine.com", "something.com"}}, repo, NewArgs([]string{"bgd", "app-name"})) 236 | 237 | Expect(b.flow).To(Equal([]string{ 238 | "delete old apps", 239 | "get current live app", 240 | "push app-name-new", 241 | "unmap 1 routes from app-name-new", 242 | "delete 1 routes", 243 | "mapped 3 routes", 244 | "rename app-name-new to app-name", 245 | })) 246 | 247 | expectedRoutes := []plugin_models.GetApp_RouteSummary{ 248 | {Host: "host1", Domain: plugin_models.GetApp_DomainFields{Name: "something.com"}}, 249 | {Host: "host2", Domain: plugin_models.GetApp_DomainFields{Name: "mine.com"}}, 250 | {Host: "host3", Domain: plugin_models.GetApp_DomainFields{Name: "common.com"}}, 251 | } 252 | 253 | Expect(b.mappedRoutes).To(ConsistOf(expectedRoutes)) 254 | }) 255 | }) 256 | 257 | Context("when scale parameters are defined", func() { 258 | It("Uses the scale values", func() { 259 | b := &BlueGreenDeployFake{liveApp: nil} 260 | p := CfPlugin{ 261 | Deployer: b, 262 | } 263 | repo := &fakes.FakeManifestReader{Yaml: `--- 264 | name: app-name 265 | memory: 16M 266 | disk_quota: 500M 267 | instances: 3 268 | hosts: 269 | - host1 270 | `} 271 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, repo, NewArgs([]string{"bgd", "app-name"})) 272 | Expect(b.flow).To(Equal([]string{ 273 | "delete old apps", 274 | "get current live app", 275 | "push app-name-new", 276 | "unmap 1 routes from app-name-new", 277 | "delete 1 routes", 278 | "mapped 1 routes", 279 | "rename app-name-new to app-name", 280 | })) 281 | scaleParameters := ScaleParameters{ 282 | Memory: int64(16), 283 | DiskQuota: int64(500), 284 | InstanceCount: 3, 285 | } 286 | Expect(*b.usedScale).To(Equal(scaleParameters)) 287 | }) 288 | }) 289 | Context("when no routes are specified in the manifest", func() { 290 | It("maps the app name as the only route", func() { 291 | b := &BlueGreenDeployFake{liveApp: nil} 292 | p := CfPlugin{ 293 | Deployer: b, 294 | } 295 | repo := &fakes.FakeManifestReader{Yaml: `--- 296 | name: app-name 297 | hosts: 298 | - host1 299 | `} 300 | 301 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, repo, NewArgs([]string{"bgd", "app-name"})) 302 | 303 | Expect(b.mappedRoutes).To(Equal([]plugin_models.GetApp_RouteSummary{ 304 | {Host: "app-name", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}}, 305 | })) 306 | }) 307 | }) 308 | }) 309 | 310 | Context("when there is a smoke test defined", func() { 311 | Context("when it succeeds", func() { 312 | var ( 313 | b *BlueGreenDeployFake 314 | p CfPlugin 315 | ) 316 | 317 | BeforeEach(func() { 318 | b = &BlueGreenDeployFake{liveApp: nil, passSmokeTest: true} 319 | p = CfPlugin{ 320 | Deployer: b, 321 | } 322 | }) 323 | 324 | It("calls methods in correct order", func() { 325 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, &fakes.FakeManifestReader{}, NewArgs([]string{"bgd", "app-name", "--smoke-test", "script/smoke-test"})) 326 | 327 | Expect(b.flow).To(Equal([]string{ 328 | "delete old apps", 329 | "get current live app", 330 | "push app-name-new", 331 | "script/smoke-test app-name-new.example.com", 332 | "unmap 1 routes from app-name-new", 333 | "delete 1 routes", 334 | "mapped 1 routes", 335 | "rename app-name-new to app-name", 336 | })) 337 | }) 338 | 339 | It("returns true", func() { 340 | result := p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, &fakes.FakeManifestReader{}, NewArgs([]string{"bgd", "app-name", "--smoke-test", "script/smoke-test"})) 341 | 342 | Expect(result).To(Equal(true)) 343 | }) 344 | }) 345 | 346 | Context("when it fails", func() { 347 | var ( 348 | b *BlueGreenDeployFake 349 | p CfPlugin 350 | ) 351 | 352 | BeforeEach(func() { 353 | b = &BlueGreenDeployFake{liveApp: nil, passSmokeTest: false} 354 | p = CfPlugin{ 355 | Deployer: b, 356 | } 357 | }) 358 | 359 | It("calls methods in correct order", func() { 360 | p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, &fakes.FakeManifestReader{}, NewArgs([]string{"bgd", "app-name", "--smoke-test", "script/smoke-test"})) 361 | 362 | Expect(b.flow).To(Equal([]string{ 363 | "delete old apps", 364 | "get current live app", 365 | "push app-name-new", 366 | "script/smoke-test app-name-new.example.com", 367 | "unmap 1 routes from app-name-new", 368 | "delete 1 routes", 369 | "rename app-name-new to app-name-failed", 370 | })) 371 | }) 372 | 373 | It("returns false", func() { 374 | result := p.Deploy(manifest.CfDomains{DefaultDomain: "example.com"}, &fakes.FakeManifestReader{}, NewArgs([]string{"bgd", "app-name", "--smoke-test", "script/smoke-test"})) 375 | 376 | Expect(result).To(Equal(false)) 377 | }) 378 | }) 379 | }) 380 | 381 | Describe("GetScaleFromManifest", func() { 382 | p := CfPlugin{} 383 | Context("when the manifest is valid", func() { 384 | It("returns the scale parameters", func() { 385 | fakeManifestReader := &fakes.FakeManifestReader{Yaml: `--- 386 | name: app-name 387 | memory: 16M 388 | disk_quota: 500M 389 | hosts: 390 | - man1 391 | `, 392 | } 393 | actualScale := p.GetScaleFromManifest("app-name", manifest.CfDomains{DefaultDomain: "example.com"}, fakeManifestReader) 394 | expectedScale := ScaleParameters{Memory: int64(16), DiskQuota: int64(500)} 395 | Expect(actualScale).To(Equal(expectedScale)) 396 | }) 397 | }) 398 | Context("the manifest is invalid", func() { 399 | It("returns an empty manifest", func() { 400 | failingFakeManifestReader := &fakes.FakeManifestReader{Err: errors.New("")} 401 | actualScale := p.GetScaleFromManifest("app-name", manifest.CfDomains{DefaultDomain: "example.com"}, failingFakeManifestReader) 402 | expectedScale := ScaleParameters{} 403 | Expect(actualScale).To(Equal(expectedScale)) 404 | }) 405 | }) 406 | }) 407 | }) 408 | 409 | Describe("SharedDomains", func() { 410 | connection := &pluginfakes.FakeCliConnection{} 411 | p := CfPlugin{Connection: connection} 412 | 413 | Context("when CF command succeeds", func() { 414 | It("returns all CF shared domains", func() { 415 | connection.CliCommandWithoutTerminalOutputStub = func(args ...string) ([]string, error) { 416 | return []string{`{ 417 | "total_results": 2, 418 | "total_pages": 1, 419 | "prev_url": null, 420 | "next_url": null, 421 | "resources": [ 422 | { 423 | "metadata": { 424 | "guid": "75049093-13e9-4520-80a6-2d6fea6542bc", 425 | "url": "/v2/shared_domains/75049093-13e9-4520-80a6-2d6fea6542bc", 426 | "created_at": "2014-10-20T09:21:39+00:00", 427 | "updated_at": null 428 | }, 429 | "entity": { 430 | "name": "my.cool.com" 431 | }}, 432 | { 433 | "metadata": { 434 | "guid": "75049093-13e9-4520-80a6-2d6fea6542bc", 435 | "url": "/v2/shared_domains/75049093-13e9-4520-80a6-2d6fea6542bc", 436 | "created_at": "2014-10-20T09:21:39+00:00", 437 | "updated_at": null 438 | }, 439 | "entity": { 440 | "name": "another.com" 441 | } 442 | } 443 | ] 444 | }`}, nil 445 | } 446 | domain, _ := p.SharedDomains() 447 | Expect(domain).To(Equal([]string{"my.cool.com", "another.com"})) 448 | }) 449 | }) 450 | 451 | Context("when CF command fails", func() { 452 | It("returns error", func() { 453 | connection.CliCommandWithoutTerminalOutputStub = func(args ...string) ([]string, error) { 454 | return nil, errors.New("cf curl failed") 455 | } 456 | _, err := p.SharedDomains() 457 | Expect(err).To(MatchError("cf curl failed")) 458 | }) 459 | }) 460 | 461 | Context("when CF command returns invalid JSON", func() { 462 | It("returns error", func() { 463 | connection.CliCommandWithoutTerminalOutputStub = func(args ...string) ([]string, error) { 464 | return []string{`{"resources": { "entity": "foo" }}`}, nil 465 | } 466 | _, err := p.SharedDomains() 467 | Expect(err).To(HaveOccurred()) 468 | }) 469 | }) 470 | }) 471 | 472 | Describe("PrivateDomains", func() { 473 | connection := &pluginfakes.FakeCliConnection{} 474 | p := CfPlugin{Connection: connection} 475 | 476 | Context("when CF command succeeds", func() { 477 | It("returns all private domains", func() { 478 | connection.CliCommandWithoutTerminalOutputStub = func(args ...string) ([]string, error) { 479 | return []string{`{ 480 | "total_results": 2, 481 | "total_pages": 1, 482 | "prev_url": null, 483 | "next_url": null, 484 | "resources": [ 485 | { 486 | "metadata": { 487 | "guid": "75049093-13e9-4520-80a6-2d6fea6542bc", 488 | "url": "/v2/private_domains/75049093-13e9-4520-80a6-2d6fea6542bc", 489 | "created_at": "2014-10-20T09:21:39+00:00", 490 | "updated_at": null 491 | }, 492 | "entity": { 493 | "name": "eu-gb.mypaas.net" 494 | }}, 495 | { 496 | "metadata": { 497 | "guid": "75049093-13e9-4520-80a6-2d6fea6542bc", 498 | "url": "/v2/private_domains/75049093-13e9-4520-80a6-2d6fea6542bc", 499 | "created_at": "2014-10-20T09:21:39+00:00", 500 | "updated_at": null 501 | }, 502 | "entity": { 503 | "name": "eu-de.mypaas.net" 504 | } 505 | } 506 | ] 507 | }`}, nil 508 | } 509 | domain, _ := p.PrivateDomains() 510 | Expect(domain).To(Equal([]string{"eu-gb.mypaas.net", "eu-de.mypaas.net"})) 511 | }) 512 | }) 513 | 514 | Context("when CF command fails", func() { 515 | It("returns error", func() { 516 | connection.CliCommandWithoutTerminalOutputStub = func(args ...string) ([]string, error) { 517 | return nil, errors.New("cf curl failed") 518 | } 519 | _, err := p.PrivateDomains() 520 | Expect(err).To(MatchError("cf curl failed")) 521 | }) 522 | }) 523 | 524 | Context("when CF command returns invalid JSON", func() { 525 | It("returns error", func() { 526 | connection.CliCommandWithoutTerminalOutputStub = func(args ...string) ([]string, error) { 527 | return []string{`{"resources": { "entity": "foo" }}`}, nil 528 | } 529 | _, err := p.PrivateDomains() 530 | Expect(err).To(HaveOccurred()) 531 | }) 532 | }) 533 | }) 534 | 535 | Describe("Unique list of routes", func() { 536 | p := CfPlugin{} 537 | 538 | Context("when listA and ListB are empty", func() { 539 | It("returns an empty list", func() { 540 | listA := []plugin_models.GetApp_RouteSummary{} 541 | listB := []plugin_models.GetApp_RouteSummary{} 542 | 543 | Expect(p.UnionRouteLists(listA, listB)).To(BeEmpty()) 544 | }) 545 | }) 546 | Context("when listA is Empty", func() { 547 | It("returns listB", func() { 548 | listA := []plugin_models.GetApp_RouteSummary{} 549 | listB := []plugin_models.GetApp_RouteSummary{{Host: "foo"}} 550 | 551 | Expect(p.UnionRouteLists(listA, listB)).To(Equal(listB)) 552 | }) 553 | }) 554 | Context("when listB is Empty", func() { 555 | It("returns listA", func() { 556 | listA := []plugin_models.GetApp_RouteSummary{{Host: "foo"}} 557 | listB := []plugin_models.GetApp_RouteSummary{} 558 | 559 | Expect(p.UnionRouteLists(listA, listB)).To(ConsistOf(listA)) 560 | }) 561 | }) 562 | Context("when listB and listA contain the same routes", func() { 563 | It("returns a list equal in contents to listB", func() { 564 | listA := []plugin_models.GetApp_RouteSummary{{Host: "foo"}} 565 | listB := []plugin_models.GetApp_RouteSummary{{Host: "foo"}} 566 | 567 | Expect(p.UnionRouteLists(listA, listB)).To(ConsistOf(listA)) 568 | }) 569 | }) 570 | Context("when listB and listA contain different routes", func() { 571 | It("returns a union of both routes", func() { 572 | listA := []plugin_models.GetApp_RouteSummary{{Host: "foo"}} 573 | listB := []plugin_models.GetApp_RouteSummary{{Host: "bar"}} 574 | 575 | Expect(p.UnionRouteLists(listA, listB)).To(ConsistOf(append(listA, listB...))) 576 | }) 577 | }) 578 | Context("when listA contains some routes not in listB", func() { 579 | It("returns a union of both routes", func() { 580 | listA := []plugin_models.GetApp_RouteSummary{{Host: "foo"}, {Host: "bar"}} 581 | listB := []plugin_models.GetApp_RouteSummary{{Host: "foo"}} 582 | 583 | Expect(p.UnionRouteLists(listA, listB)).To(ConsistOf(listA)) 584 | }) 585 | }) 586 | Context("when listB contains some routes not in listA", func() { 587 | It("returns a union of both routes", func() { 588 | listA := []plugin_models.GetApp_RouteSummary{{Host: "foo"}} 589 | listB := []plugin_models.GetApp_RouteSummary{{Host: "foo"}, {Host: "bar"}} 590 | 591 | Expect(p.UnionRouteLists(listA, listB)).To(ConsistOf(listB)) 592 | }) 593 | }) 594 | Context("when list A and List B contain both shared and non-shared routes", func() { 595 | It("returns a union of both routes", func() { 596 | listA := []plugin_models.GetApp_RouteSummary{{Host: "shared"}, {Host: "listAOnly"}} 597 | listB := []plugin_models.GetApp_RouteSummary{{Host: "shared"}, {Host: "listBOnly"}} 598 | 599 | expectedRoutes := []plugin_models.GetApp_RouteSummary{{Host: "shared"}, {Host: "listAOnly"}, {Host: "listBOnly"}} 600 | 601 | Expect(p.UnionRouteLists(listA, listB)).To(ConsistOf(expectedRoutes)) 602 | }) 603 | }) 604 | Context("when list A is nil", func() { 605 | It("returns list B", func() { 606 | listB := []plugin_models.GetApp_RouteSummary{{Host: "foo"}} 607 | 608 | Expect(p.UnionRouteLists(nil, listB)).To(ConsistOf(listB)) 609 | }) 610 | }) 611 | Context("when list B is nil", func() { 612 | It("returns list A", func() { 613 | listA := []plugin_models.GetApp_RouteSummary{{Host: "foo"}} 614 | 615 | Expect(p.UnionRouteLists(listA, nil)).To(ConsistOf(listA)) 616 | }) 617 | }) 618 | Context("when list A & list B are nil", func() { 619 | It("returns an empty Array", func() { 620 | Expect(p.UnionRouteLists(nil, nil)).To(BeEmpty()) 621 | }) 622 | }) 623 | }) 624 | 625 | Describe("FQDN", func() { 626 | It("returns the fqdn of the route", func() { 627 | route := plugin_models.GetApp_RouteSummary{Host: "testroute", Domain: plugin_models.GetApp_DomainFields{Name: "example.com"}} 628 | Expect(FQDN(route)).To(Equal("testroute.example.com")) 629 | }) 630 | }) 631 | }) 632 | 633 | type BlueGreenDeployFake struct { 634 | flow []string 635 | liveApp *plugin_models.GetAppModel 636 | appSshEnabled bool 637 | passSmokeTest bool 638 | mappedRoutes []plugin_models.GetApp_RouteSummary 639 | deletedRoutes []plugin_models.GetApp_RouteSummary 640 | scale *ScaleParameters 641 | usedScale *ScaleParameters 642 | } 643 | 644 | func (p *BlueGreenDeployFake) Setup(connection plugin.CliConnection) { 645 | p.flow = append(p.flow, "setup") 646 | } 647 | 648 | func (p *BlueGreenDeployFake) GetScaleParameters(appName string) (ScaleParameters, error) { 649 | return ScaleParameters{}, nil 650 | } 651 | 652 | func (p *BlueGreenDeployFake) PushNewApp(appName string, route plugin_models.GetApp_RouteSummary, 653 | manifestPath string, scaleParameters ScaleParameters) { 654 | p.usedScale = &scaleParameters 655 | p.flow = append(p.flow, fmt.Sprintf("push %s", appName)) 656 | } 657 | 658 | func (p *BlueGreenDeployFake) DeleteAllAppsExceptLiveApp(string) { 659 | p.flow = append(p.flow, "delete old apps") 660 | } 661 | 662 | func (p *BlueGreenDeployFake) DeleteAllAppsExceptLiveAndFailedApp(string) { 663 | p.flow = append(p.flow, "delete old apps except failed ones") 664 | } 665 | 666 | func (p *BlueGreenDeployFake) LiveApp(string) (string, []plugin_models.GetApp_RouteSummary) { 667 | p.flow = append(p.flow, "get current live app") 668 | if p.liveApp == nil { 669 | return "", nil 670 | } else { 671 | return p.liveApp.Name, p.liveApp.Routes 672 | } 673 | } 674 | func (p *BlueGreenDeployFake) RunSmokeTests(script string, fqdn string) bool { 675 | p.flow = append(p.flow, fmt.Sprintf("%s %s", script, fqdn)) 676 | return p.passSmokeTest 677 | } 678 | 679 | func (p *BlueGreenDeployFake) RemapRoutesFromLiveAppToNewApp(liveApp plugin_models.GetAppModel, newApp plugin_models.GetAppModel) { 680 | p.flow = append(p.flow, fmt.Sprintf("remap routes from %s to %s", liveApp.Name, newApp.Name)) 681 | } 682 | 683 | func (p *BlueGreenDeployFake) RenameApp(app string, newName string) { 684 | p.flow = append(p.flow, fmt.Sprintf("rename %s to %s", app, newName)) 685 | } 686 | 687 | func (p *BlueGreenDeployFake) MapRoutesToApp(appName string, routes ...plugin_models.GetApp_RouteSummary) { 688 | p.mappedRoutes = routes 689 | p.flow = append(p.flow, fmt.Sprintf("mapped %d routes", len(routes))) 690 | } 691 | 692 | func (p *BlueGreenDeployFake) UnmapRoutesFromApp(oldAppName string, routes ...plugin_models.GetApp_RouteSummary) { 693 | p.flow = append(p.flow, fmt.Sprintf("unmap %d routes from %s", len(routes), oldAppName)) 694 | } 695 | 696 | func (p *BlueGreenDeployFake) DeleteRoutes(routes ...plugin_models.GetApp_RouteSummary) { 697 | p.deletedRoutes = routes 698 | p.flow = append(p.flow, fmt.Sprintf("delete %d routes", len(routes))) 699 | } 700 | 701 | func (p *BlueGreenDeployFake) CheckSshEnablement(app string) bool { 702 | p.flow = append(p.flow, fmt.Sprintf("check ssh enablement for '%s'", app)) 703 | return strings.Contains(app, "ssh-enabled-app") 704 | } 705 | 706 | func (p *BlueGreenDeployFake) SetSshAccess(app string, enableSsh bool) { 707 | p.flow = append(p.flow, fmt.Sprintf("set ssh enablement for '%s' to '%v'", app, enableSsh)) 708 | } 709 | --------------------------------------------------------------------------------