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
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "container_control"
|
4
|
+
|
5
|
+
module Cutlass
|
6
|
+
# Boots containers and tears 'em down
|
7
|
+
#
|
8
|
+
# Has a single method ContainerBoot#call which returns an instance of
|
9
|
+
#
|
10
|
+
# boot = ContainerBoot.new(image_id: @image.id)
|
11
|
+
# boot.call do |container_control|
|
12
|
+
# container_control.class # => ContainerControl
|
13
|
+
# container_control.bash_exec("pwd")
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
# The number one reason to boot a container is to be able to exercise a booted server from
|
17
|
+
# within the container. To do this you need to tell docker want port to expose
|
18
|
+
# inside of the container. Docker will expose that port and bind it to a free port
|
19
|
+
# on the "host" i.e. your local machine. From there you can make queries to various
|
20
|
+
# docker ports:
|
21
|
+
#
|
22
|
+
# boot = ContainerBoot.new(image_id: @image.id, expose_ports: [8080])
|
23
|
+
# boot.call do |container_control|
|
24
|
+
# local_port = container_control.get_host_port(8080)
|
25
|
+
#
|
26
|
+
# `curl localhost:#{local_port}`
|
27
|
+
# end
|
28
|
+
#
|
29
|
+
# Note: Booting a container only works if the image has an ENTRYPOINT that does
|
30
|
+
# not exit.
|
31
|
+
#
|
32
|
+
# Note: Running `bash_exec` commands from this context gives you a raw access to the
|
33
|
+
# container. It does not execute the container's entrypoint. That means if you're running
|
34
|
+
# inside of a CNB image, that env vars won't be set and the directory might be different.
|
35
|
+
class ContainerBoot
|
36
|
+
def initialize(image_id:, expose_ports: [])
|
37
|
+
@expose_ports = Array(expose_ports)
|
38
|
+
config = {
|
39
|
+
"Image" => image_id,
|
40
|
+
"ExposedPorts" => {},
|
41
|
+
"HostConfig" => {
|
42
|
+
"PortBindings" => {}
|
43
|
+
}
|
44
|
+
}
|
45
|
+
|
46
|
+
port_bindings = config["HostConfig"]["PortBindings"]
|
47
|
+
|
48
|
+
@expose_ports.each do |port|
|
49
|
+
config["ExposedPorts"]["#{port}/tcp"] = {}
|
50
|
+
|
51
|
+
# If we do not specify a port, Docker will grab a random unused one:
|
52
|
+
port_bindings["#{port}/tcp"] = [{"HostPort" => ""}]
|
53
|
+
end
|
54
|
+
|
55
|
+
@container = Docker::Container.create(config)
|
56
|
+
end
|
57
|
+
|
58
|
+
def call
|
59
|
+
raise "Must call with a block" unless block_given?
|
60
|
+
|
61
|
+
@container.start!
|
62
|
+
|
63
|
+
puts @container.logs(stdout: 1) if Cutlass.debug?
|
64
|
+
puts @container.logs(stderr: 1) if Cutlass.debug?
|
65
|
+
|
66
|
+
yield ContainerControl.new(@container, ports: @expose_ports)
|
67
|
+
rescue => error
|
68
|
+
raise error, <<~EOM
|
69
|
+
message #{error.message}
|
70
|
+
|
71
|
+
boot stdout: #{@container.logs(stdout: 1)}
|
72
|
+
boot stderr: #{@container.logs(stderr: 1)}
|
73
|
+
EOM
|
74
|
+
ensure
|
75
|
+
@container&.delete(force: true)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cutlass
|
4
|
+
# This class is exposed via a ContainerBoot instance
|
5
|
+
#
|
6
|
+
# Once a container is booted, if a port is bound an instance will
|
7
|
+
# return the local port that can be used to send network requests to the container.
|
8
|
+
#
|
9
|
+
# In addition bash commands can be executed via ContainerControl#bash_exec
|
10
|
+
#
|
11
|
+
class ContainerControl
|
12
|
+
def initialize(container, ports: [])
|
13
|
+
@container = container
|
14
|
+
@ports = ports
|
15
|
+
end
|
16
|
+
|
17
|
+
def get_host_port(port)
|
18
|
+
raise "Port not bound inside container: #{port}, bound ports: #{@ports.inspect}" unless @ports.include?(port)
|
19
|
+
@container.json["NetworkSettings"]["Ports"]["#{port}/tcp"][0]["HostPort"]
|
20
|
+
end
|
21
|
+
|
22
|
+
def contains_file?(path)
|
23
|
+
bash_exec("[[ -f '#{path}' ]]", exception_on_failure: false).status == 0
|
24
|
+
end
|
25
|
+
|
26
|
+
def get_file_contents(path)
|
27
|
+
bash_exec("cat '#{path}'").stdout
|
28
|
+
end
|
29
|
+
|
30
|
+
def bash_exec(cmd, exception_on_failure: true)
|
31
|
+
stdout_ish, stderr, status = @container.exec(["bash", "-c", cmd])
|
32
|
+
|
33
|
+
result = BashResult.new(stdout: stdout_ish.first, stderr: stderr, status: status)
|
34
|
+
|
35
|
+
return result if result.success?
|
36
|
+
return result unless exception_on_failure
|
37
|
+
|
38
|
+
raise <<~EOM
|
39
|
+
bash_exec(#{cmd}) failed
|
40
|
+
|
41
|
+
stdout: #{stdout}
|
42
|
+
stderr: #{stderr}
|
43
|
+
EOM
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cutlass
|
4
|
+
# Diffs docker images
|
5
|
+
#
|
6
|
+
# diff = DockerDiff.new
|
7
|
+
#
|
8
|
+
# diff.call.changed? # => false
|
9
|
+
#
|
10
|
+
# BashResult.run("docker build .")
|
11
|
+
#
|
12
|
+
# diff.call.changed? # => true
|
13
|
+
class DockerDiff
|
14
|
+
def initialize(before_ids: nil, get_image_ids_proc: -> { Docker::Image.all.map(&:id) })
|
15
|
+
@before_ids = before_ids || get_image_ids_proc.call
|
16
|
+
@get_image_ids_proc = get_image_ids_proc
|
17
|
+
end
|
18
|
+
|
19
|
+
def call
|
20
|
+
DiffValue.new(
|
21
|
+
before_ids: @before_ids,
|
22
|
+
now_ids: @get_image_ids_proc.call
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
class DiffValue
|
27
|
+
attr_reader :diff_ids
|
28
|
+
|
29
|
+
def initialize(before_ids:, now_ids:)
|
30
|
+
@diff_ids = now_ids - before_ids
|
31
|
+
end
|
32
|
+
|
33
|
+
def changed?
|
34
|
+
@diff_ids.any?
|
35
|
+
end
|
36
|
+
|
37
|
+
def same?
|
38
|
+
!changed?
|
39
|
+
end
|
40
|
+
|
41
|
+
def leaked_images
|
42
|
+
diff_ids.map do |id|
|
43
|
+
Docker::Image.get(id)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def to_s
|
48
|
+
leaked_images.map do |image|
|
49
|
+
" tags: #{image.info["RepoTags"]}, id: #{image.id}"
|
50
|
+
end.join($/)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cutlass
|
4
|
+
# Diffs the environment
|
5
|
+
#
|
6
|
+
# diff = EnvDiff.new
|
7
|
+
#
|
8
|
+
# diff.changed? # => false
|
9
|
+
#
|
10
|
+
# ENV["LOL"] = "rofl"
|
11
|
+
#
|
12
|
+
# diff.changed? # => true
|
13
|
+
class EnvDiff
|
14
|
+
attr_reader :before_env, :env, :skip_keys
|
15
|
+
|
16
|
+
def initialize(before_env: ENV.to_h.dup, skip_keys: [], env: ENV)
|
17
|
+
@env = env
|
18
|
+
@before_env = before_env.freeze
|
19
|
+
@skip_keys = skip_keys
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_s
|
23
|
+
env_keys.map do |k|
|
24
|
+
next if @env[k] == @before_env[k]
|
25
|
+
|
26
|
+
" ENV['#{k}'] changed from '#{@before_env[k]}' to '#{@env[k]}'"
|
27
|
+
end.compact.join($/)
|
28
|
+
end
|
29
|
+
|
30
|
+
def same?
|
31
|
+
!changed?
|
32
|
+
end
|
33
|
+
|
34
|
+
def changed?
|
35
|
+
env_keys.detect do |k|
|
36
|
+
@env[k] != @before_env[k]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def env_keys
|
41
|
+
keys = (@before_env.keys + @env.keys) - skip_keys
|
42
|
+
keys.uniq
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cutlass
|
4
|
+
# Converts a buildpack in a local directory into an image that pack can use natively
|
5
|
+
#
|
6
|
+
# MY_BUILDPACK = LocalBuildpack.new(directory: "/tmp/muh_buildpack").call
|
7
|
+
# puts MY_BUILDPACK.name #=> "docker:://cutlass_local_buildpack_abcd123"
|
8
|
+
#
|
9
|
+
# Cutlass.config do |config|
|
10
|
+
# config.default_buildapacks = [MY_BUILDPACK]
|
11
|
+
# end
|
12
|
+
#
|
13
|
+
# Note: Make sure that any built images are torn down in in your test suite
|
14
|
+
#
|
15
|
+
# config.after(:suite) do
|
16
|
+
# MY_BUILDPACK.teardown
|
17
|
+
#
|
18
|
+
# Cutlass::CleanTestEnv.check
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
class LocalBuildpack
|
22
|
+
private
|
23
|
+
|
24
|
+
attr_reader :image_name
|
25
|
+
|
26
|
+
public
|
27
|
+
|
28
|
+
def initialize(directory:)
|
29
|
+
@built = false
|
30
|
+
@directory = Pathname(directory)
|
31
|
+
@image_name = "cutlass_local_buildpack_#{SecureRandom.hex(10)}"
|
32
|
+
end
|
33
|
+
|
34
|
+
def teardown
|
35
|
+
return unless built?
|
36
|
+
|
37
|
+
image = Docker::Image.get(image_name)
|
38
|
+
image.remove(force: true)
|
39
|
+
end
|
40
|
+
|
41
|
+
def name
|
42
|
+
call
|
43
|
+
"docker://#{image_name}"
|
44
|
+
end
|
45
|
+
|
46
|
+
def call
|
47
|
+
return if built?
|
48
|
+
raise "must be directory: #{@directory}" unless @directory.directory?
|
49
|
+
|
50
|
+
@built = true
|
51
|
+
|
52
|
+
call_build_sh
|
53
|
+
call_pack_buildpack_package
|
54
|
+
|
55
|
+
self
|
56
|
+
end
|
57
|
+
|
58
|
+
private def call_pack_buildpack_package
|
59
|
+
raise "must contain package.toml: #{@directory}" unless @directory.join("package.toml").exist?
|
60
|
+
|
61
|
+
command = "pack buildpack package #{image_name} --config #{@directory.join("package.toml")} --format=image"
|
62
|
+
result = BashResult.run(command)
|
63
|
+
|
64
|
+
puts command if Cutlass.debug?
|
65
|
+
puts result.stdout if Cutlass.debug?
|
66
|
+
puts result.stderr if Cutlass.debug?
|
67
|
+
|
68
|
+
return if result.success?
|
69
|
+
raise <<~EOM
|
70
|
+
While packaging meta-buildpack: pack exited with status code #{result.status},
|
71
|
+
indicating an error and failed build!
|
72
|
+
|
73
|
+
stdout: #{result.stdout}
|
74
|
+
stderr: #{result.stderr}
|
75
|
+
EOM
|
76
|
+
end
|
77
|
+
|
78
|
+
private def call_build_sh
|
79
|
+
build_sh = @directory.join("build.sh")
|
80
|
+
return unless build_sh.exist?
|
81
|
+
|
82
|
+
command = "cd #{@directory} && bash #{build_sh}"
|
83
|
+
result = BashResult.run(command)
|
84
|
+
|
85
|
+
puts command if Cutlass.debug?
|
86
|
+
puts result.stdout if Cutlass.debug?
|
87
|
+
puts result.stderr if Cutlass.debug?
|
88
|
+
|
89
|
+
return if result.success?
|
90
|
+
|
91
|
+
raise <<~EOM
|
92
|
+
Buildpack build step failed!
|
93
|
+
|
94
|
+
stdout: #{result.stdout}
|
95
|
+
stderr: #{result.stderr}
|
96
|
+
EOM
|
97
|
+
end
|
98
|
+
|
99
|
+
def built?
|
100
|
+
@built
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cutlass
|
4
|
+
# Build an image with `pack` and cloud native buildpacks
|
5
|
+
#
|
6
|
+
# begin
|
7
|
+
# build = PackBuild.new(app_dir: dir, buildpacks: ["heroku/ruby"], builder: "heroku/buildpacks:18")
|
8
|
+
# build.call
|
9
|
+
#
|
10
|
+
# build.stdout # => "...Successfully built image"
|
11
|
+
# build.success? # => true
|
12
|
+
# ensure
|
13
|
+
# build.teardown
|
14
|
+
# end
|
15
|
+
#
|
16
|
+
class PackBuild
|
17
|
+
private
|
18
|
+
|
19
|
+
attr_reader :app_dir, :config, :builder, :image_name, :buildpacks, :exception_on_failure, :env_arguments
|
20
|
+
|
21
|
+
public
|
22
|
+
|
23
|
+
def initialize(
|
24
|
+
app_dir:,
|
25
|
+
config: {},
|
26
|
+
builder: nil,
|
27
|
+
buildpacks: [],
|
28
|
+
image_name: Cutlass.default_image_name,
|
29
|
+
exception_on_failure: true
|
30
|
+
)
|
31
|
+
@app_dir = app_dir
|
32
|
+
@builder = builder
|
33
|
+
@image_name = image_name
|
34
|
+
@env_arguments = config.map { |key, value| "--env #{key}=#{value}" }.join(" ")
|
35
|
+
@exception_on_failure = exception_on_failure
|
36
|
+
@image = nil
|
37
|
+
@result = nil
|
38
|
+
|
39
|
+
@buildpacks = Array(buildpacks).map do |buildpack|
|
40
|
+
if buildpack.respond_to?(:name)
|
41
|
+
buildpack.name
|
42
|
+
else
|
43
|
+
buildpack
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def image_id
|
49
|
+
raise "No image ID, container was not successfully built, #{error_message}" if @image.nil?
|
50
|
+
@image.id
|
51
|
+
end
|
52
|
+
|
53
|
+
def result
|
54
|
+
raise "Must execute method `call` first" unless @result
|
55
|
+
|
56
|
+
@result
|
57
|
+
end
|
58
|
+
|
59
|
+
def teardown
|
60
|
+
@image&.remove(force: true)
|
61
|
+
end
|
62
|
+
|
63
|
+
def stdout
|
64
|
+
result.stdout
|
65
|
+
end
|
66
|
+
|
67
|
+
def stderr
|
68
|
+
result.stderr
|
69
|
+
end
|
70
|
+
|
71
|
+
def call
|
72
|
+
puts pack_command if Cutlass.debug?
|
73
|
+
call_pack
|
74
|
+
|
75
|
+
puts @result.stdout if Cutlass.debug?
|
76
|
+
puts @result.stderr if Cutlass.debug?
|
77
|
+
self
|
78
|
+
end
|
79
|
+
|
80
|
+
def failed?
|
81
|
+
!success?
|
82
|
+
end
|
83
|
+
|
84
|
+
def success?
|
85
|
+
result.success?
|
86
|
+
end
|
87
|
+
|
88
|
+
private def call_pack
|
89
|
+
@result = BashResult.run(pack_command)
|
90
|
+
|
91
|
+
if @result.success?
|
92
|
+
@image = Docker::Image.get(image_name)
|
93
|
+
else
|
94
|
+
@image = nil
|
95
|
+
|
96
|
+
raise error_message if exception_on_failure
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
private def error_message
|
101
|
+
<<~EOM
|
102
|
+
Pack exited with status code #{@result.status}, indicating a build failed
|
103
|
+
|
104
|
+
command: #{pack_command}
|
105
|
+
stdout: #{stdout}
|
106
|
+
stderr: #{stderr}
|
107
|
+
EOM
|
108
|
+
end
|
109
|
+
|
110
|
+
def builder_arg
|
111
|
+
"-B #{builder}" if builder
|
112
|
+
end
|
113
|
+
|
114
|
+
def pack_command
|
115
|
+
"pack build #{image_name} --path #{app_dir} #{builder_arg} --buildpack #{buildpacks.join(",")} #{env_arguments} #{"-v" if Cutlass.debug?}"
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|