cutlass 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.circleci/config.yml +58 -0
- data/.github/dependabot.yml +9 -0
- data/.github/workflows/check_changelog.yml +16 -0
- data/.gitignore +13 -0
- data/.rspec +4 -0
- data/.rubocop.yml +13 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +13 -0
- data/Gemfile.lock +77 -0
- data/LICENSE.txt +21 -0
- data/README.md +245 -0
- data/Rakefile +12 -0
- data/SECURITY.md +7 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/cutlass.gemspec +35 -0
- data/lib/cutlass.rb +92 -0
- data/lib/cutlass/app.rb +172 -0
- data/lib/cutlass/bash_result.rb +56 -0
- data/lib/cutlass/clean_test_env.rb +74 -0
- data/lib/cutlass/container_boot.rb +78 -0
- data/lib/cutlass/container_control.rb +46 -0
- data/lib/cutlass/docker_diff.rb +54 -0
- data/lib/cutlass/env_diff.rb +45 -0
- data/lib/cutlass/local_buildpack.rb +103 -0
- data/lib/cutlass/pack_build.rb +118 -0
- data/lib/cutlass/version.rb +6 -0
- metadata +89 -0
data/Rakefile
ADDED
data/SECURITY.md
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
## Security
|
2
|
+
|
3
|
+
Please report any security issue to [security@salesforce.com](mailto:security@salesforce.com)
|
4
|
+
as soon as it is discovered. This library limits its runtime dependencies in
|
5
|
+
order to reduce the total cost of ownership as much as can be, but all consumers
|
6
|
+
should remain vigilant and have their security stakeholders review all third-party
|
7
|
+
products (3PP) like this one and their dependencies.
|
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "cutlass"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require "irb"
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/cutlass.gemspec
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/cutlass/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "cutlass"
|
7
|
+
spec.version = Cutlass::VERSION
|
8
|
+
spec.authors = ["schneems"]
|
9
|
+
spec.email = ["richard.schneeman+foo@gmail.com"]
|
10
|
+
|
11
|
+
spec.summary = "Write CNB integration tests for Pack in Ruby with cutlass"
|
12
|
+
spec.description = "Have you ever had problems opening a `pack` age? Try something sharper, try CUTLASS!"
|
13
|
+
spec.homepage = "https://github.com/heroku/cutlass"
|
14
|
+
spec.license = "MIT"
|
15
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
|
16
|
+
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/heroku/cutlass"
|
19
|
+
spec.metadata["changelog_uri"] = "https://github.com/heroku/cutlass/blob/main/CHANGELOG.md"
|
20
|
+
|
21
|
+
# Specify which files should be added to the gem when it is released.
|
22
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
23
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
24
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
25
|
+
end
|
26
|
+
spec.bindir = "exe"
|
27
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
28
|
+
spec.require_paths = ["lib"]
|
29
|
+
|
30
|
+
# Uncomment to register a new dependency of your gem
|
31
|
+
spec.add_dependency "docker-api", ">= 2.0"
|
32
|
+
|
33
|
+
# For more information and examples about making a new gem, checkout our
|
34
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
35
|
+
end
|
data/lib/cutlass.rb
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tempfile"
|
4
|
+
require "fileutils"
|
5
|
+
require "pathname"
|
6
|
+
require "securerandom"
|
7
|
+
|
8
|
+
require "docker" # docker-api gem
|
9
|
+
|
10
|
+
require_relative "cutlass/version"
|
11
|
+
|
12
|
+
# Cutlass
|
13
|
+
module Cutlass
|
14
|
+
# Error
|
15
|
+
class Error < StandardError; end
|
16
|
+
|
17
|
+
def self.config
|
18
|
+
yield self
|
19
|
+
end
|
20
|
+
|
21
|
+
class << self
|
22
|
+
# Cutlass.default_builder
|
23
|
+
# Cutlass.default_buildpack_paths
|
24
|
+
attr_accessor :default_builder, :default_buildpack_paths
|
25
|
+
end
|
26
|
+
|
27
|
+
@default_buildpack_paths = []
|
28
|
+
@default_repo_dirs = []
|
29
|
+
def self.default_repo_dirs=(dirs)
|
30
|
+
@default_repo_dirs = Array(dirs).map { |dir| Pathname(dir) }
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.default_repo_dirs
|
34
|
+
@default_repo_dirs
|
35
|
+
end
|
36
|
+
|
37
|
+
# Given a full path that exists it will return the same path.
|
38
|
+
# Given the name of a directory within the default repo dirs,
|
39
|
+
# it will match and return a full path
|
40
|
+
def self.resolve_path(path)
|
41
|
+
return Pathname(path) if Dir.exist?(path)
|
42
|
+
|
43
|
+
children = @default_repo_dirs.map(&:children).flatten
|
44
|
+
resolved = children.detect { |p| p.basename.to_s == path }
|
45
|
+
|
46
|
+
return resolved if resolved
|
47
|
+
|
48
|
+
raise(<<~EOM)
|
49
|
+
No such directory name: #{path.inspect}
|
50
|
+
|
51
|
+
#{children.map(&:basename).join($/)}
|
52
|
+
EOM
|
53
|
+
end
|
54
|
+
|
55
|
+
def self.debug?
|
56
|
+
ENV["CUTLASS_DEBUG"] || ENV["DEBUG"]
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.default_image_name
|
60
|
+
"cutlass_image_#{SecureRandom.hex(10)}"
|
61
|
+
end
|
62
|
+
|
63
|
+
# Runs the block in a process fork to isolate memory
|
64
|
+
# or environment changes such as ENV var modifications
|
65
|
+
def self.in_fork
|
66
|
+
Tempfile.create("stdout") do |tmp_file|
|
67
|
+
pid = fork do
|
68
|
+
$stdout.reopen(tmp_file, "a")
|
69
|
+
$stderr.reopen(tmp_file, "a")
|
70
|
+
$stdout.sync = true
|
71
|
+
$stderr.sync = true
|
72
|
+
yield
|
73
|
+
Kernel.exit!(0) # needed for https://github.com/seattlerb/minitest/pull/683
|
74
|
+
end
|
75
|
+
Process.waitpid(pid)
|
76
|
+
|
77
|
+
if $?.success?
|
78
|
+
print File.read(tmp_file)
|
79
|
+
else
|
80
|
+
raise File.read(tmp_file)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
require_relative "cutlass/bash_result"
|
87
|
+
require_relative "cutlass/app"
|
88
|
+
require_relative "cutlass/clean_test_env"
|
89
|
+
|
90
|
+
require_relative "cutlass/local_buildpack"
|
91
|
+
require_relative "cutlass/pack_build"
|
92
|
+
require_relative "cutlass/container_boot"
|
data/lib/cutlass/app.rb
ADDED
@@ -0,0 +1,172 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cutlass
|
4
|
+
# Top level class for interacting with a "pack" app
|
5
|
+
#
|
6
|
+
# Cutlass::App.new(
|
7
|
+
# path_to_rails_app,
|
8
|
+
# buildpacks: "heroku/ruby",
|
9
|
+
# builder: "heroku/buildpacks:18"
|
10
|
+
# ).transaction do |app|
|
11
|
+
# app.pack_build
|
12
|
+
#
|
13
|
+
# expect(result.stdout).to include("Successfully built image")
|
14
|
+
# end
|
15
|
+
class App
|
16
|
+
attr_reader :builds, :config, :builder, :buildpacks, :exception_on_failure, :image_name, :tmpdir
|
17
|
+
|
18
|
+
def initialize(
|
19
|
+
source_path_name,
|
20
|
+
config: {},
|
21
|
+
warn_io: $stderr,
|
22
|
+
builder: Cutlass.default_builder,
|
23
|
+
image_name: Cutlass.default_image_name,
|
24
|
+
buildpacks: Cutlass.default_buildpack_paths,
|
25
|
+
exception_on_failure: true
|
26
|
+
)
|
27
|
+
@tmpdir = nil
|
28
|
+
@source_path = nil
|
29
|
+
|
30
|
+
@builds = []
|
31
|
+
@on_teardown = []
|
32
|
+
|
33
|
+
@config = config
|
34
|
+
@warn_io = warn_io
|
35
|
+
@builder = builder
|
36
|
+
@image_name = image_name
|
37
|
+
@buildpacks = buildpacks
|
38
|
+
@source_path_name = source_path_name
|
39
|
+
@exception_on_failure = exception_on_failure
|
40
|
+
end
|
41
|
+
|
42
|
+
def stdout
|
43
|
+
last_build.stdout
|
44
|
+
end
|
45
|
+
|
46
|
+
def stderr
|
47
|
+
last_build.stderr
|
48
|
+
end
|
49
|
+
|
50
|
+
def success?
|
51
|
+
last_build.success?
|
52
|
+
end
|
53
|
+
|
54
|
+
def fail?
|
55
|
+
last_build.fail?
|
56
|
+
end
|
57
|
+
|
58
|
+
def last_build
|
59
|
+
raise "You must `pack_build` first" if builds.empty?
|
60
|
+
|
61
|
+
builds.last
|
62
|
+
end
|
63
|
+
|
64
|
+
def run(command, exception_on_failure: true)
|
65
|
+
command = docker_command(command)
|
66
|
+
result = BashResult.run(command)
|
67
|
+
|
68
|
+
raise(<<~EOM) if result.failed? && exception_on_failure
|
69
|
+
Command "#{command}" failed
|
70
|
+
|
71
|
+
stdout: #{result.stdout}
|
72
|
+
stderr: #{result.stderr}
|
73
|
+
status: #{result.status}
|
74
|
+
EOM
|
75
|
+
|
76
|
+
result
|
77
|
+
end
|
78
|
+
|
79
|
+
private def docker_command(command)
|
80
|
+
"docker run --entrypoint='/cnb/lifecycle/launcher' #{image_name} #{command.to_s.shellescape}"
|
81
|
+
end
|
82
|
+
|
83
|
+
def run_multi(command, exception_on_failure: true)
|
84
|
+
raise "No block given" unless block_given?
|
85
|
+
|
86
|
+
thread = Thread.new do
|
87
|
+
yield run(command, exception_on_failure: exception_on_failure)
|
88
|
+
end
|
89
|
+
|
90
|
+
on_teardown { thread.join }
|
91
|
+
end
|
92
|
+
|
93
|
+
def start_container(expose_ports: [])
|
94
|
+
raise "No block given" unless block_given?
|
95
|
+
|
96
|
+
ContainerBoot.new(image_id: last_build.image_id, expose_ports: expose_ports).call do |container|
|
97
|
+
yield container
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def pack_build
|
102
|
+
build = PackBuild.new(
|
103
|
+
config: config,
|
104
|
+
app_dir: @tmpdir,
|
105
|
+
builder: builder,
|
106
|
+
buildpacks: buildpacks,
|
107
|
+
image_name: image_name,
|
108
|
+
exception_on_failure: exception_on_failure
|
109
|
+
)
|
110
|
+
on_teardown { build.teardown }
|
111
|
+
|
112
|
+
@builds << build
|
113
|
+
yield build.call
|
114
|
+
end
|
115
|
+
|
116
|
+
def transaction
|
117
|
+
raise "No block given" unless block_given?
|
118
|
+
|
119
|
+
in_dir do
|
120
|
+
yield self
|
121
|
+
ensure
|
122
|
+
teardown
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def in_dir
|
127
|
+
Dir.mktmpdir do |dir|
|
128
|
+
@tmpdir = Pathname(dir)
|
129
|
+
|
130
|
+
FileUtils.copy_entry(source_path, @tmpdir)
|
131
|
+
|
132
|
+
Dir.chdir(@tmpdir) do
|
133
|
+
yield @tmpdir
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def teardown
|
139
|
+
errors = []
|
140
|
+
@on_teardown.reverse_each do |callback|
|
141
|
+
# Attempt to run all teardown callbacks
|
142
|
+
|
143
|
+
callback.call
|
144
|
+
rescue => e
|
145
|
+
errors << e
|
146
|
+
|
147
|
+
@warn_io.puts <<~EOM
|
148
|
+
|
149
|
+
Error in teardown #{callback.inspect}
|
150
|
+
|
151
|
+
It will be raised after all teardown blocks have completed
|
152
|
+
|
153
|
+
#{e.message}
|
154
|
+
|
155
|
+
#{e.backtrace.join($/)}
|
156
|
+
EOM
|
157
|
+
end
|
158
|
+
ensure
|
159
|
+
errors.each do |e|
|
160
|
+
raise e
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def on_teardown(&block)
|
165
|
+
@on_teardown << block
|
166
|
+
end
|
167
|
+
|
168
|
+
private def source_path
|
169
|
+
@source_path ||= Cutlass.resolve_path(@source_path_name)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require "open3"
|
2
|
+
|
3
|
+
module Cutlass
|
4
|
+
# Value object containing the results of bash commands
|
5
|
+
#
|
6
|
+
# result = BashResult.run("echo 'lol')
|
7
|
+
# result.stdout # => "lol"
|
8
|
+
# result.status # => 0
|
9
|
+
# result.success? # => true
|
10
|
+
class BashResult
|
11
|
+
def self.run(command)
|
12
|
+
stdout, stderr, status = Open3.capture3(command)
|
13
|
+
BashResult.new(stdout: stdout, stderr: stderr, status: status)
|
14
|
+
end
|
15
|
+
|
16
|
+
# @return [String]
|
17
|
+
attr_reader :stdout
|
18
|
+
|
19
|
+
# @return [String]
|
20
|
+
attr_reader :stderr
|
21
|
+
|
22
|
+
# @return [Numeric]
|
23
|
+
attr_reader :status
|
24
|
+
|
25
|
+
# @param stdout [String]
|
26
|
+
# @param stderr [String]
|
27
|
+
# @param status [Numeric]
|
28
|
+
def initialize(stdout:, stderr:, status:)
|
29
|
+
@stdout = stdout
|
30
|
+
@stderr = stderr
|
31
|
+
@status = status.to_i
|
32
|
+
end
|
33
|
+
|
34
|
+
# @return [Boolean]
|
35
|
+
def success?
|
36
|
+
@status == 0
|
37
|
+
end
|
38
|
+
|
39
|
+
def failed?
|
40
|
+
!success?
|
41
|
+
end
|
42
|
+
|
43
|
+
# Testing helper methods
|
44
|
+
def include?(value)
|
45
|
+
stdout.include?(value)
|
46
|
+
end
|
47
|
+
|
48
|
+
def match?(value)
|
49
|
+
stdout.match?(value)
|
50
|
+
end
|
51
|
+
|
52
|
+
def match(value)
|
53
|
+
stdout.match(value)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "env_diff"
|
4
|
+
require_relative "docker_diff"
|
5
|
+
|
6
|
+
module Cutlass
|
7
|
+
# Ensure that your environment variables and docker images aren't leaking in tests
|
8
|
+
#
|
9
|
+
# Docker image leaking is disabled by default in development. Set CUTLASS_CHECK_DOCKER
|
10
|
+
# in CI.
|
11
|
+
#
|
12
|
+
# CleanTestEnv.record
|
13
|
+
# CleanTestEnv.check(docker: true)
|
14
|
+
# # => nil
|
15
|
+
#
|
16
|
+
# BashResult.run("docker build .")
|
17
|
+
#
|
18
|
+
# CleanTestEnv.check(docker: true)
|
19
|
+
# # => Error: Docker images have leaked
|
20
|
+
#
|
21
|
+
# The common practice is to use this in rspec hooks
|
22
|
+
#
|
23
|
+
# config.before(:suite) do
|
24
|
+
# Cutlass::CleanTestEnv.record
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# config.after(:suite) do
|
28
|
+
# Cutlass::CleanTestEnv.check
|
29
|
+
# end
|
30
|
+
class CleanTestEnv
|
31
|
+
@before_images = []
|
32
|
+
@skip_keys = ["HEROKU_API_KEY"]
|
33
|
+
|
34
|
+
def self.skip_key(key)
|
35
|
+
@skip_keys << key
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.record
|
39
|
+
@env_diff = EnvDiff.new(skip_keys: @skip_keys)
|
40
|
+
@docker_diff = DockerDiff.new
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.check(docker: ENV["CUTLASS_CHECK_DOCKER"])
|
44
|
+
check_env
|
45
|
+
check_images if docker
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.check_env
|
49
|
+
raise "Must call `record` first" if @env_diff.nil?
|
50
|
+
return if @env_diff.same?
|
51
|
+
|
52
|
+
raise <<~EOM
|
53
|
+
Something mutated the environment on accident
|
54
|
+
|
55
|
+
Diff:
|
56
|
+
#{@env_diff}
|
57
|
+
EOM
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.check_images
|
61
|
+
diff = @docker_diff.call
|
62
|
+
return if diff.same?
|
63
|
+
|
64
|
+
raise <<~EOM
|
65
|
+
Docker images have leaked
|
66
|
+
|
67
|
+
Your tests are generating docker images that were not cleaned up
|
68
|
+
|
69
|
+
#{diff}
|
70
|
+
|
71
|
+
EOM
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|