Cutlass
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.
Installation
Add this line to your application's Gemfile:
gem 'cutlass'
Setup
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_containerinterface your executor options are limited.
TODO: cutlass init command
Initial Config
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
Use
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
Initial Config (LocalBuildpack for package.toml)
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.
API
Cutlass::App Init options:
- @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:defaultit 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=> String, Hash=> String] env vars to set against the app before it is built.
- @param exception_on_failure: [Boolean] when truthy failures on
app.pack_buildwill result in an exception. Default is true.
Cutlass::App object API
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.transactionYields 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 theteardowncallbacks are called on the application. If an image has been built usingpack_buildthe end of the transaction will clean it up.app.pack_buildYields a block with aCutlass::BashResult. Triggers a build via thepackCLI. It can be invoked multiple times inside of a transaction for testing cache behavior.app.start_containerboots a container instance and connects it to a local port. Yields aCutlass::ContainerControlinstance with information about the container such as the port it is connected to.app.runTakes 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: falseapp.run_multitakes a string with a shell command and executes it async inside of docker. Yields aCutlass::BashResultobject. 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_dirYields a block with itself. Copies over example repo to a temporary path. When the block is finished executing the path is cleaned up.app.teardownTriggers any "teardown" callbacks, such as waiting onrun_mutliblocks to complete. This is called automatically viaapp.transaction
Cutlass::BashResult
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.stdoutStdout from the command that was runresult.stderrStderr from the command that was runresult.statusStatus code integer from the command that was runresult.success?Truthy is status code was zeroresult.fail?Falsey if status code was zero
Cutlass::ContainerControl
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>)Runscaton a given file. Returns a BashResult object
Test Help
Clean ENV check
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
Clean ENV
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
Debugging
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.
Ruby Protips:
- Rspec basics
- Ruby basics
- My favorite way to manipualate things on disk is through the Pathname object which wraps many
FileandFileUtilscommands.
Development
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.
Contributing
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.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the Cutlass project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.