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.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
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"
@@ -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