cutlass 0.1.0

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