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