cutlass 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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