Hack and slash your way to Cloud Native Buildpack (CNB) stability with cutlass! This library is similar in spirit to heroku_hatchet, but instead of building on Heroku infrastructure cutlass utilizes pack to locally build and verify buildpack behavior.
Add this line to your application's Gemfile:
gem 'cutlass'
It's assumed you've already got your project set up with rspec. If not see https://github.com/heroku/hatchet#hatchet-init, though using Hatchet is not required to use Cutlass.
You'll want to set up your app to run on CircleCI. Here's reference configs:
- buildpacks-jvm note the versions of pack, the pack orb, and the executor. If you want to use the
start_container
interface your executor options are limited.
TODO: cutlass init
command
In your spec_helper.rb
configure your default stack:
# spec/spec_helper.rb
Cutlass.config do |config|
config.default_builder = "heroku/buildpacks:18"
# Where do your test fixtures live?
config.default_repo_dirs = [File.join(__dir__, "..", "repos", "ruby_apps")]
# Where does your buildpack live?
# Can be a directory or a Cutlass:LocalBuildpack instance
config.default_buildpack_paths = [File.join(__dir__, "..")]
end
Initialize an instance with Cutlass::App.new
Cutlass::App.new(
"ruby-getting-started" # Directory name in your default repos dir
config: { RAILS_ENV: "production" },
builder: "heroku/buildpacks:18",
buildpacks: ["heroku/nodejs-engine", File.join("..")],
exception_on_failure: false
)
Once initialized call methods on the instance:
Cutlass::App.new("ruby-getting-started").transaction do |app|
# Safely modify files on disk before building the project
Pathname(app.tmpdir).join("Procfile").write("web: rails s")
# Build the app with `pack_build` using a block or regular method call
app.pack_build do |result|
expect(result.stdout).to include("SUCCESS")
end
# Build the app again with the non-block form of this method
app.pack_build
app.stdout # Grabs stdout from last build
app.stderr # Grabs stdout from last build
# Executes a `docker run` command in a background thread
app.run_multi("ruby -v") do |result|
expect(result.stdout).to match("2.7.2")
expect(result.status).to eq(0)
end
# Binds the port 8080 inside of the container to a port on your host's localhost
# so you can make network requests to the instance. This requires the app
# to have an ENTRYPOINT in the docker file, such as an app with a `web` declaration
# that also uses the `heroku/procfile` buildpack. The entrypoint must not exit
# or the container will shut down.
#
# Another caveat to using this feature is that your "host" machine needs to be running on
# a machine, not inside of a docker instance otherwise the networking will not bind correctly to the
# child docker instance
#
# Basically there's a ton of caveats to using this feature. Tread lightly.
app.start_container(expose_ports: [8080]) do |container|
response = Excon.get("http://localhost:#{container.get_host_port(8080)}/", :idempotent => true, :retry_limit => 5, :retry_interval => 1)
expect(response.body).to eq("Welcome to rails")
# Warning, this does not use the CNB entrypoint so it's in a different dir
# and doesn't have env vars set
expect(container.bash_exec("pwd")).to eq("/workspace")
expect(container.get_file_contents("/workspace/Gemfile.lock")).to_not include("BUNDLED WITH")
end
end
If your needs a package.toml
to function, then you can use Cutlass::LocalBuildpack. In your config:
# spec/spec_helper.rb
MY_BUILDPACK = LocalBuildpack.new(directory: "/tmp/muh_buildpack_dir_with_packagetoml").call
Cutlass.config do |config|
config.default_buildapacks = [MY_BUILDPACK]
end
Then you'll need to tear down the buildpack at the end of the test suite so the resulting docker image doesn't leak:
# spec/spec_helper.rb
RSpec.configure do |config|
config.after(:suite) do
MY_BUIDLPACK.teardown
end
end
In additon to the standard package.toml
interface, if this directory has a build.sh
file that file will be executed.
- @param repo_name [String] the path to a directory on disk, or the name of a directory inside of the
config.default_repos_dir
. - @param builder [String] the name of a CNB "builder" used to build the app against. Defaults to
config.default_builder
. - @param buildpacks [Array] the array of buildpacks to build the app against. Defaults to
config.default_buildpack_paths
. If you pass in a symbol of:default
it will substituteCutlass.default_buildpack_paths
. That means passing in["heroku/nodejs", :default]
is a shortcut for["heroku/nodejs, Cutlass.default_buildpack_paths].flatten
. - @param config [Hash{Symbol => String}, Hash{String => String}] env vars to set against the app before it is built.
- @param exception_on_failure: [Boolean] when truthy failures on
app.pack_build
will result in an exception. Default is true.
The app object acts as the main interface between your test suite and much of the behavior of cutlass. Here are the suggested methods:
app.transaction
Yields a block with itself. Copies over the example repo to a temporary path. When the block is finished executing, the path is cleaned up and theteardown
callbacks are called on the application. If an image has been built usingpack_build
the end of the transaction will clean it up.app.pack_build
Yields a block with aCutlass::BashResult
. Triggers a build via thepack
CLI. It can be invoked multiple times inside of a transaction for testing cache behavior.app.start_container
boots a container instance and connects it to a local port. Yields aCutlass::ContainerControl
instance with information about the container such as the port it is connected to.app.run
Takes a string with a shell command and executes it in docker syncronously, returns a BashResult object. By default will raise an error if the status code returns non-zero. Can be disabled with kwargexception_on_failure: false
app.run_multi
takes a string with a shell command and executes it async inside of docker. Yields aCutlass::BashResult
object. By default will raise an error if the status code returns non-zero. Can be disabled with kwargexception_on_failure: false
These methods can also be used, but they're lower level and are not needed when using app.transaction
:
app.in_dir
Yields a block with itself. Copies over example repo to a temporary path. When the block is finished executing the path is cleaned up.app.teardown
Triggers any "teardown" callbacks, such as waiting onrun_mutli
blocks to complete. This is called automatically viaapp.transaction
An instance of BashResult is returned whenever Cutlass interacts with the shell or a shell-like object. For instance app.pack_build
runs the pack
command on the CLI and yelds a BashResult object with the results
result.stdout
Stdout from the command that was runresult.stderr
Stderr from the command that was runresult.status
Status code integer from the command that was runresult.success?
Truthy is status code was zeroresult.fail?
Falsey if status code was zero
Once built an app can app.start_container
to yield a ContainerControl object.
container.get_host_port(<port>)
Returns the port on the host machine (your computer, not docker) that docker is bound to- Warning: These following commands do not use the CNB entry point so CNB env vars are not loaded and it my be a different dir than you're expecting
container.bash_exec(<command>)
Executes a bash command inside of a running container. Returns a BashResult object. By default this will raise an exception if the command returns non-zero exit code. Use kwargcontainer.bash_exec(<command>, exception_on_failure: false)
to disable. Returns a BashResult object.container.contains_file?(<file path>)
Checks to see if a given file exists on disk. Returns a BashResult objectcontainer.file_contents(<file path>)
Runscat
on a given file. Returns a BashResult object
Make sure that environment variables do not leak from one test to another by configuring a check to run after your suite finishes:
# spec/spec_helper.rb
RSpec.configure do |config|
config.before(:suite) do
Cutlass::CleanTestEnv.record
end
config.after(:suite) do
Cutlass::CleanTestEnv.check
end
end
If one of your tests does modify your local process memory and you can't change that, then you can wrap that code inside:
Cutlass.in_fork do
# Code here is executed in a fork
# non-zero exit code will result in errors being re-raised
end
To get a firehose of info including the pack
command used to build your app, you can set env vars CUTLASS_DEBUG=1
or DEBUG=1
.
- Rspec basics
- Ruby basics
- My favorite way to manipualate things on disk is through the Pathname object which wraps many
File
andFileUtils
commands.
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and the created tag, and push the .gem
file to rubygems.org.
Be sensitive about test time. If a fixture needs a docker image, but not a CNB built image...use a simple dockerfile as a fixture as it's faster.
All tests locally that take more than a second are tagged with slow: true
. The test suite is pretty snappy, but you can iterate faster by running tests tagged without slow first and then if they pass running the slow ones:
alias fast="bundle exec rspec --tag \~slow && bundle exec rspec --tag slow"
Tests on CI are runn with parallel_split_test
which you can also use locally. All flags given to pst are passed to rspec.
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/cutlass. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
See the LICENSE file.
Everyone interacting in the Cutlass project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.