Build a CI/CD Pipeline in the Cloud: Part Three

WRITTEN BY Dave Nicolette

Continuous Delivery

This is Part 3 of a four-part series of posts that walks you through setting up a working continuous delivery pipeline in the cloud.

In Part 1 we set the stage for our project and you received a homework assignment to sign up for several online services.

In Part 2 we configured our version control, dependency management, and run management facilities and started to get familiar with our development environment.

In this installment, we’ll test-drive the first thin vertical slice of application functionality.

In Part 4, we’ll build out the rest of the CI/CD pipeline.

Review the Story

We were just about to start test-driving our application. Let’s review our first Story before we proceed:

Story:

In order to validate architectural assumptions 
We want to see a simple transaction flow all the way through the system

Acceptance criteria: 

When a user submits a "Hello" request 
Then the system responds with "Hello"

We glossed over what it means for the user to submit a request, and we haven’t discussed any details about how we want the service to respond to that request. Let’s clarify.

We’ve been talking about “saying hello,” but that’s a fairly general statement. What do we actually want the request to look like, and what do we want the microservice to return?

The core principle of engineering, found on signs in labs the world over, is: “Don’t do anything stupid on purpose.” This principle applies equally to software development.

We’ll do plenty of stupid things by accident, so there’s no sense in piling on. In the context of validating our architectural assumptions for an Internet-based microservice, that means balancing two goals:

  • We want to honor the ancient wisdom of programmers, YAGNI (You Ain’t Gonna Need It). People have learned that when we try to anticipate future requirements and code for them in advance to “save time,” we end up doubling our work because when the future finally arrives we invariably discover the requirements are different from our prediction. We have to rip out what we wrote before. So we want to try and build only what’s really needed right now. We used to try and build for the future by accident (or innocently, with the best of intentions) all the time. Now we know better, so if we do it again we’ll be violating the core principle of engineering.
  • We want to avoid going overboard with YAGNI. There are certain things we know will be needed in a viable cloud-based continuous delivery pipeline. There are certain things we know a microservice must support. We can’t predict what specific requests our customers will want us to support in the future, but we can predict some of these common things.

So, we know the microservice API has to be versioned. Why not define “saying hello” to mean that the microservice knows how to respond to an inquiry such as, “Dear microservice, do you understand version 1.0.0?” And it should package the response as a JSON document.

With that in mind, let’s say we want the RESTful URI to look like this:

http://[server][domain][:port]/v1.0.0/

…and we want the response document to look like this:

{
  "service": "playservice",
  "version": "1.0.0",
  "status": "supported"
}

We’ll need to handle the case when the request is improperly formatted, too. For purposes of validating our architectural assumptions, I’m going to say the “happy path” case will be sufficient. You may disagree. This is a judgment call that pertains to the balance between doing something stupid and taking YAGNI too far. There isn’t a single “right” answer.

Step 6: Write the Hello Functionality

By convention, Ruby applications are usually structured with separate directories for the production code and the test code. The production directory is usually called app or lib. The test directory may be called test or spec, depending on which unit testing tools are used. We’re using Rspec, and the convention is to name the test directory spec. Let’s create those directories now. They are subdirectories of playservice (or whatever the root directory of your project is called).

cd playservice
mkdir app 
mkdir spec

Until now, we’ve been doing configuration work. Now we’re going to do software development work. We’ll start by creating specs (short for “specifications”) that describe the behavior we want to see from our “hello” function.

The specs are executable, and they will “fail” (display error messages) when the application does not behave according to expectations. When we have wrangled the software into submission, the specs will report “success.” At that point, we can clean up whatever mess we may have made in the course of making it work.

Then we’ll repeat the whole sad business over and over again until we’ve finished the application. That’s test-driven development (TDD) in a nutshell. TDD is generally regarded as the “proper” way to develop code that we type in with our own fingers, as opposed to assembling pre-built building blocks or using a code generator.

We could just throw a couple of lines of code together that spit out the JSON document we’re looking for, and call it a day. That would adhere to the YAGNI principle. But it would be stupid, because we know a couple of things about software.

One of the basic things about software is separation of concerns. We’re talking about multiple concerns here. The logic to recognize “version 1.0.0” and provide the response data is one concern. The logic to package that data in the form of a JSON document and return it to the requester is a different concern. So we know we’ll need two pieces of software to complete this Story, if we want to do it in a way that helps us validate our architectural assumptions, as opposed to some sloppy, random, hacky way.

Note: Some people like to call “basic things about software” by the name, “software engineering principles.” It sounds better, I guess.

There are little tricks or tips for using any tool effectively. I’m not going to ask you to go and learn about Rspec on your own. When using Rspec, it’s often useful to define some common things in a file named (by convention) spec_helper.rb. Let’s create that file for our project now, while things are still simple.

Create a new file and enter this data into it:

$LOAD_PATH.unshift File.expand_path('../app', __FILE__)

require 'rspec'

Now save the file as playservice/spec/spec_helper.rb.

The logic that recognizes the version number, etc., doesn’t need to “know” it’s running as part of a microservice. It only needs to know that when it receives a string value that it recognizes, it returns a few other string values. The simplest implementation will return the three values the microservice will need in order to populate the response document, as described above. Let’s express that behavior in a spec:

require_relative "../app/handler"

describe 'playservice: ' do 

  before(:example) do 
    @handler = Handler.new
  end

  context 'verifying version support: ' do
     it 'reports that version 1.0.0 is supported' do 
       expect(@handler.default).to eq({
         "service" => "playservice",
         "version" => "1.0.0",
         "status" => "supported"})     
     end
  end
end

We can run our specs using Rspec as follows. Note we have to be in the project root directory when we do this.

rspec spec/handler_spec.rb 

You should see a result like this:

$ rspec spec/handler_spec.rb
F

Failures:

  1) playservice:  verifying version support:  reports that version 1.0.0 is supported
     Failure/Error: @handler = Handler.new

     NameError:
       uninitialized constant Handler
     # ./spec/handler_spec.rb:4:in `block (2 levels) in '

Finished in 0.00689 seconds (files took 0.27559 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/handler_spec.rb:8 # playservice:  verifying version support:  reports that version 1.0.0 is supported

This is good! It’s telling us (in its own special way) that the behavior we’re looking for hasn’t been implemented yet. And that’s the truth! It’s very useful to have specs that tell us the truth about our code. Otherwise, we’d just be swatting flies in the dark.

The output is telling us the following:

  • “uninitialized constant Handler” is Ruby’s way of saying, “AFAIK there’s no such thing as Handler”. We haven’t created Handler yet, so this is exactly where we expect to be at this point.
  • “rspec ./spec/handler_spec.rb:8 # playservice: verifying version support: reports that version 1.0.0 is supported” is Rspec’s way of saying, “I detected the problem at line 8 in file handler_spec.rb in a block labeled ‘reports that version 1.0.0 is supported’, which is inside a block labled ‘verifying version support’, which is inside a block labeled ‘playservice'”. You’ll be grateful for that level of detail when you’ve built up an application that has many spec files containing many blocks.

Our next step is to create a Ruby class named Handler that will know how to produce the expected output. I won’t ask you to learn Ruby instantaneously. A key thing to know is that a Ruby class is written in Camel Case while the name of the file that contains the source code is written in Snake Case.

I can hear you saying, Wait a minute! What do animals have to do with anything?

Here’s a phrase written in Camel Case, like a Ruby class name:

ThisCouldBeARubyClass

…and here’s the same phrase written in Snake Case, with the .rb suffix as if it were a Ruby source file name:

this_could_be_a_ruby_class.rb

Here’s our Handle class. Create another new file with these contents and save it under the app subdirectory.

class Handler 
  def default 
    { 
      "service" => "playservice",
      "version" => "1.0.0",
      "status" => "supported"
    }
  end
end 

Now when we run the spec again, we get this output:

$ rspec spec/handler_spec.rb
.

Finished in 0.00743 seconds (files took 0.13385 seconds to load)
1 example, 0 failures

In case you’re coming to this from a non-programming background, that was test-driven development, right there. You just did TDD. Granted, in most cases it takes many more of these little steps to build up a useful amount of code, but that was genuine TDD. You’re a programmer now! Don’t tell your friends, or every time you go to a party they’ll ask you to fix their personal computer.

==> Commit! <==

Step 7: Construct the Initial Playservice Application

The second piece of logic we need is the piece that routes the request to the version responder and packages the output from that method as a JSON document to send back to the requester.

The code that receives the request and returns the response isn’t an isolated Ruby method. It’s the actual microservice code. We can’t test-drive it with a microtest example that is completely self-contained. We have to test-drive it at the “integration” level, with the web server running.

I can hear you saying, Wait a minute! What do you mean, “level?” You never said anything about “levels” before! Are you going to keep adding more and more stuff?

That’s really two questions. Re Question #1: There are multiple levels of testing (or checking) and multiple levels of test-driving. Microtests are the base. They’re the smallest cases, and they have no dependencies on any code outside of themselves.

The next level up from there might be called “unit” or “component” or “integration” or something like that, depending on whom you ask. Those test cases may have some external dependencies, and they exercise a larger chunk of the application than the microtests do. We have to take one step up from microtests to test-drive the microservice code itself.

Re Question #2: Yes.

We ran our initial microtest case by executing Rspec directly on the command line. It’s good to know how to do that. In a “real” project, we would use a build tool to run builds and tests. For Ruby, the standard build tool is Rake. Rake uses a configuration file that it expects to find in the project root directory and it expects to be named Rakefile. Let’s create a Rakefile for our project now.

We’ll configure Rake so that we can run microtests and integration tests separately. Create a new file and put this data in it:

require 'rspec/core/rake_task'

RSpec::Core::RakeTask.new(:spec) do |t|
  t.rspec_opts = "--tag ~integration"
  t.pattern = Dir.glob('spec/**/*_spec.rb')
end

RSpec::Core::RakeTask.new(:integration) do |t|
  t.rspec_opts = "--tag integration"
  t.pattern = Dir.glob('spec/**/*_spec.rb')
end

task :default => :spec

Let’s do a quick check to see that we can execute the same spec as we did before, but running Rake instead of Rspec directly. Try this command:

rake

We could have run rake spec, but because we defined spec to be the default Rake task, we don’t have to type that much. That’s handy, as we’ll normally run the microtest-level specs far more frequently than anything else. The result should look like this:

$ rake
/home/cabox/.rvm/rubies/ruby-2.1.2/bin/ruby -I/home/cabox/.rvm/gems/ruby-2.1.2/gems/rspec-support-3.7.1/lib:/home/cabox/.rvm/gems/ruby-2.1.2/gems/rspec-core-3.7.1/lib /home/cabox/.rvm/gems/ruby-2.1.2/gems/rspec-core-3.7.1/exe/rspec spec/handler_spec.rb --tag ~integration
Run options: exclude {:integration=>true}
.

Finished in 0.00267 seconds (files took 0.18323 seconds to load)
1 example, 0 failures

That means our single microtest example produced the same result as it did when we ran it directly with Rspec.

Now let’s try running “integration tests.”

rake integration 

This time, the result looks like this:

$ rake integration
/home/cabox/.rvm/rubies/ruby-2.1.2/bin/ruby -I/home/cabox/.rvm/gems/ruby-2.1.2/gems/rspec-support-3.7.1/lib:/home/cabox/.rvm/gems/ruby-2.1.2/gems/rspec-core-3.7.1/lib /home/cabox/.rvm/gems/ruby-2.1.2/gems/rspec-core-3.7.1/exe/rspec --pattern spec/\*\*\{,/\*/\*\*\}/\*_spec.rb --tag integration
Run options: include {:integration=>true}

All examples were filtered out

Finished in 0.0004 seconds (files took 0.16691 seconds to load)
0 examples, 0 failures

It’s telling us that no examples were executed. That’s because we haven’t written an integration test yet.

==> Commit! <==

You’ve probably noticed that we keep switching around among different activities like infrastructure setup, tool configuration, application coding, testing, and test automation. That’s pretty normal these days. The era when each of those little tasks was carried out by a separate team is rapidly waning. Some specialization is useful, but excessive specialization tends to slow things down. One thing I hope you’re learning from this exercise is that these various tasks aren’t so terribly difficult that any of them really requires a deep expert for the majority of routine work <= note the caveat

Now let’s test-drive the microservice call that returns the JSON document we defined earlier. We’ll mark that example as “integration” so we can run it separately from “unit” checks.

require 'json'
require 'rspec'
require 'rest-client'

describe 'playservice: ' do
  context 'verifying version support: ' do 
    it 'says that it supports version 1.0.0', :integration => true do
      response = RestClient.get 'http://0.0.0.0:4567/'
      expect(JSON.parse(response)['service'])
        .to eq('playservice')
    end
  end
end  

Don’t worry about the details if you’re not into Ruby, but do notice a couple of key things about that file:

First, on the line that starts with it, there’s a specification “:integration => true”. This is how Rake will know to choose this example when running “integration” tests.

Second, notice there are some new “require” statements. We’ll need to update our Gemfile and run bundle install to pick up these additional dependencies. The reason for them is to access the microservice and to interpret the JSON response document we expect the microservice to return.

Gemfile should now contain:

source 'http://rubygems.org'

gem 'sinatra', '1.4.8'
gem 'thin'
gem 'json'

group :test do
  gem 'rspec'
  gem 'rest-client'
end

Our microservice will need the json gem in production, but our project is not a “rest client”. The Rspec examples will need to act as a REST client, so the rest-client gem is needed only for running tests.

Remember to run bundle install after updating Gemfile, and remember to…

==> Commit! <==

Now we’ll use Rack to start the web server and we’ll try our integration test:

rackup -p 4567 -o 0.0.0.0 &

We’re specifying port 4567, as that’s the default port for Thin. We’re telling the web server to listen on host 0.0.0.0, as that’s the default for Code Anywhere. Later you’ll see that we don’t use these setting for the production deployment.

If everything is in order, you should see something like this appear on the console:

$ rackup -p 4567 -o 0.0.0.0 &
[1] 1148
cabox@box-codeanywhere:~/workspace/playservice$ Thin web server (v1.7.2 codename Bachmanity)
Maximum connections set to 1024
Listening on 0.0.0.0:4567, CTRL+C to stop

One of the tabs Code Anywhere opened when you created the connection to your container contains information about the container. It looks something like this:

Look for the part that looks like this:

It’s telling you the URL to use to access the service that’s running in your container. Open a browser tab and enter that URL, with the port number and path info appended, like this:

http://playcontainer-davenicolette339440.codeanyapp.com:4567/v1.0.0

That’s the URL where we’ll access our microservice. But we don’t want to hard-code that value in our spec file. It will be different in different environments. We’re in our development environment now, but we’ll also be running the specs in the continuous integration environment. In keeping with 12 Factor design guidelines for microservices, we want the URL to be provided to the application through an environment variable.

export PLAY_URL=http://playcontainer-davenicolette339440.codeanyapp.com:4567

…and we modify the spec to read the environment variable:

require 'json'
require 'rspec'
require 'rest-client'

describe 'playservice: ' do
  context 'verifying version support: ' do 
    it 'says that it supports version 1.0.0', :integration => true do
      response = RestClient.get "#{ENV['PLAY_URL']}/v1.0.0/"
      expect(JSON.parse(response)['service'])
        .to eq('playservice')
    end
  end
end

Now when we run integration tests, we see that it doesn’t know what we’re talking about

rake integration
$ rake integration
13.64.149.219 - - [12/Feb/2018:10:51:56 -0500] "GET /v1.0.0/ HTTP/1.1" 404 513 0.0015
F

Failures:

  1) playservice:  verifying version support:  says that it supports version 1.0.0
     Failure/Error: response = RestClient.get "#{ENV['PLAY_URL']}/v1.0.0/"

     RestClient::NotFound:
       404 Not Found

Finished in 0.07062 seconds (files took 0.83018 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/version_check_spec.rb:7 # playservice:  verifying version support:  says that it supports version 1.0.0

This is where we expect to be at this point, as we haven’t written the microservice yet. Let’s do that now. Create a file with the following contents and save it as app/playservice.rb.

require 'sinatra'
require 'thin' get '/v1.0.0/' do 'Nothing to see here' end

Obviously, Nothing to see here is not correct. We’re taking small steps to test-drive the solution. We’re currently moving from ‘404’ (not found) to ‘found it, but it gives the wrong answer’. That’s part of the TDD process.

After restarting the web server and running rake integration again, we get the following result:

$ rake integration
13.64.149.219 - - [12/Feb/2018:11:01:01 -0500] "GET /v1.0.0/ HTTP/1.1" 200 19 0.0155
F

Failures:

  1) playservice:  verifying version support:  says that it supports version 1.0.0
     Failure/Error:
       expect(JSON.parse(response)['service'])
         .to eq('playservice')

     JSON::ParserError:
       765: unexpected token at 'Nothing to see here'

Finished in 0.11879 seconds (files took 0.8121 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/version_check_spec.rb:7 # playservice:  verifying version support:  says that it supports version 1.0.0

So far, so good. Now let’s add code to playservice.rb to make use of our Handler class.

require 'sinatra'
require 'thin'
require 'json'
require_relative "./handler"

get '/v1.0.0/' do 
  Handler.new.default.to_json
end

Now when we restart the web server and run rake integration again, we get:

rake integration
Run options: include {:integration=>true}
13.64.149.219 - - [12/Feb/2018:11:09:27 -0500] "GET /v1.0.0/ HTTP/1.1" 200 64 0.0135
.
Finished in 0.08721 seconds (files took 0.81328 seconds to load)
1 example, 0 failures

At this point, we have a working thin slice of functionality for the playservice. We have unit and integration checks working. It’s time to…

==> Commit! <==

…and then we can build out the rest of the CI/CD pipeline. We’ll do that in the next and final instalment in the series.

One more thing to note: We’re doing this exercise in Ruby, so our application development tools are all Ruby-based: Bundler, Rack, Rake, Rspec, and so forth. Bear in mind that microservices can be written in any programming language. Each language has its own set of tools. The names and syntax differ, but this type of work calls for the same general functional categories of tools regardless of the programming language.

We chose Ruby for this exercise because it’s relatively simple to work with. Those of you coming from a non-technical background might find that statement a bit odd, as all this stuff sure lookscomplicated. Even so, doing the same thing in most other languages entails more complicated (or at least more tedious) configuration and coding than Ruby.

Come back for Part 4, and we’ll get all this flowing seamlessly from your fingertips to the cloud.

leave a comment

Leave a comment

Your email address will not be published. Required fields are marked *