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.
@@ -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