drunker 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.
data/drunker.gemspec ADDED
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'drunker/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "drunker"
8
+ spec.version = Drunker::VERSION
9
+ spec.authors = ["Kazuma Watanabe"]
10
+ spec.email = ["watassbass@gmail.com"]
11
+
12
+ spec.summary = %q{Distributed CLI runner on AWS CodeBuild}
13
+ spec.description = %q{Distributed CLI runner on AWS CodeBuild}
14
+ spec.homepage = "https://github.com/wata727/drunker"
15
+ spec.license = "MIT"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+ spec.bindir = "exe"
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.add_development_dependency "bundler", "~> 1.14"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "rspec", "~> 3.0"
27
+ spec.add_development_dependency "timecop", "~> 0.8"
28
+
29
+ spec.add_runtime_dependency "thor", "~> 0.19"
30
+ spec.add_runtime_dependency "rubyzip", "~> 1.2"
31
+ spec.add_runtime_dependency "aws-sdk", "~> 2"
32
+ spec.add_runtime_dependency "drunker-aggregator-pretty", "~> 0.1"
33
+ end
data/exe/drunker ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ $LOAD_PATH << File.join(__dir__, "../lib")
4
+ require "drunker"
5
+
6
+ Drunker::CLI.start(ARGV)
data/lib/drunker.rb ADDED
@@ -0,0 +1,20 @@
1
+ require "thor"
2
+ require "zip"
3
+ require "pathname"
4
+ require "aws-sdk"
5
+ require "json"
6
+ require "logger"
7
+ require "erb"
8
+ require "yaml"
9
+
10
+ require "drunker/version"
11
+ require "drunker/cli"
12
+ require "drunker/source"
13
+ require "drunker/artifact"
14
+ require "drunker/artifact/layer"
15
+ require "drunker/executor"
16
+ require "drunker/executor/iam"
17
+ require "drunker/executor/builder"
18
+ require "drunker/config"
19
+ require "drunker/aggregator"
20
+ require "drunker/aggregator/base"
@@ -0,0 +1,9 @@
1
+ module Drunker
2
+ class Aggregator
3
+ def self.create(config)
4
+ require config.aggregator.name
5
+ klass = Object.const_get(config.aggregator.name.split("-").map(&:capitalize).join("::"))
6
+ klass.new
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ module Drunker
2
+ class Aggregator
3
+ class Base
4
+ def run(layers)
5
+ raise NotImplementedError.new("You must implement #{self.class}##{__method__}")
6
+ end
7
+
8
+ def exit_status(layers)
9
+ raise NotImplementedError.new("You must implement #{self.class}##{__method__}")
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,73 @@
1
+ module Drunker
2
+ class Artifact
3
+ attr_reader :bucket
4
+ attr_reader :stdout
5
+ attr_reader :stderr
6
+ attr_reader :exit_status
7
+
8
+ def initialize(config:, logger:)
9
+ timestamp = Time.now.to_i.to_s
10
+ s3 = Aws::S3::Resource.new(client: Aws::S3::Client.new(config.aws_client_options))
11
+
12
+ @bucket = s3.create_bucket(bucket: "drunker-artifact-store-#{timestamp}")
13
+ logger.info("Created artifact bucket: #{bucket.name}")
14
+ @name = "drunker_artifact_#{timestamp}"
15
+ @stdout = "drunker_artifact_#{timestamp}_stdout.txt"
16
+ @stderr = "drunker_artifact_#{timestamp}_stderr.txt"
17
+ @exit_status = "drunker_artifact_#{timestamp}_exit_status.txt"
18
+ @builds = []
19
+ @logger = logger
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ type: "S3",
25
+ location: bucket.name,
26
+ namespace_type: "BUILD_ID",
27
+ }
28
+ end
29
+
30
+ def layers
31
+ @layers ||= builds.each_with_object([]) do |build, layers|
32
+ project_name, build_id = build.split(":")
33
+ layers << Layer.new(build_id: build).tap do |layer|
34
+ begin
35
+ layer.stdout = fetch_content("#{build_id}/#{project_name}/#{stdout}")
36
+ layer.stderr = fetch_content("#{build_id}/#{project_name}/#{stderr}")
37
+ layer.exit_status = fetch_content("#{build_id}/#{project_name}/#{exit_status}")
38
+ rescue Aws::S3::Errors::NoSuchKey
39
+ logger.debug("Artifact not found")
40
+ layer.invalid!
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ def set_build(build)
47
+ @builds << build
48
+ logger.debug("Set build: { build: #{build}, artifact: #{name} }")
49
+ end
50
+
51
+ def replace_build(before:, after:)
52
+ builds.delete(before)
53
+ logger.debug("Unset build: { build: #{before}, artifact: #{name} }")
54
+ set_build(after)
55
+ end
56
+
57
+ def delete
58
+ bucket.delete!
59
+ logger.info("Deleted bucket: #{bucket.name}")
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :builds
65
+ attr_reader :name
66
+ attr_reader :logger
67
+
68
+ def fetch_content(object_id)
69
+ logger.debug("Get artifact: #{object_id}")
70
+ bucket.object(object_id).get.body.string
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,30 @@
1
+ module Drunker
2
+ class Artifact
3
+ class Layer
4
+ attr_reader :build_id
5
+ attr_reader :exit_status
6
+ attr_accessor :stdout
7
+ attr_accessor :stderr
8
+
9
+ def initialize(build_id:, stdout: nil, stderr: nil, exit_status: nil)
10
+ @build_id = build_id
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ @exit_status = exit_status.to_i
14
+ @invalid = false
15
+ end
16
+
17
+ def exit_status=(exit_status)
18
+ @exit_status = exit_status.to_i
19
+ end
20
+
21
+ def invalid?
22
+ @invalid
23
+ end
24
+
25
+ def invalid!
26
+ @invalid = true
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,74 @@
1
+ module Drunker
2
+ class CLI < Thor
3
+ desc "run [IMAGE] [COMMAND]", "Run a command on CodeBuild"
4
+ method_option :config, :type => :string, :default => ".drunker.yml", :desc => "Location of config file"
5
+ method_option :concurrency, :type => :numeric, :default => 1, :desc => "Build concurrency"
6
+ method_option :compute_type, :type => :string, :default => "small", :enum => %w(small medium large), :desc => "Container compute type"
7
+ method_option :timeout, :type => :numeric, :default => 60, :desc => "Build timeout in minutes, should be between 5 and 480"
8
+ method_option :env, :type => :hash, :default => {}, :desc => "Environment variables in containers"
9
+ method_option :buildspec, :type => :string, :desc => "Location of custom buildspec"
10
+ method_option :file_pattern, :type => :string, :default => "**/*", :desc => "FILES target file pattern, can use glob to specify, but files beginning with a dot are ignored."
11
+ method_option :aggregator, :type => :string, :default => "pretty", :desc => "Aggregator name. If you want to use custom aggregator, please install that beforehand."
12
+ method_option :loglevel, :type => :string, :default => "info", :enum => %w(debug info warn error fatal), :desc => "Output log level"
13
+ method_option :debug, :type => :boolean, :default => false, :desc => "Enable debug mode. This mode does not delete the AWS resources created by Drunker"
14
+ method_option :access_key, :type => :string, :desc => "AWS access key token used by Drunker"
15
+ method_option :secret_key, :type => :string, :desc => "AWS secret key token used by Drunker"
16
+ method_option :region, :type => :string, :desc => "AWS region in which resources is created by Drunker"
17
+ method_option :profile_name, :type => :string, :desc => "AWS shared credentials profile name used by Drunker"
18
+ def _run(image, *commands)
19
+ loglevel = options[:debug] ? "DEBUG" : options[:loglevel].upcase
20
+ logger = Logger.new(STDERR).tap do |logger|
21
+ logger.level = Logger.const_get(loglevel)
22
+ logger.formatter = Proc.new { |severity, _datetime, _progname, message| "#{severity}: #{message}\n" } unless loglevel == "DEBUG"
23
+ end
24
+ config = Drunker::Config.new(image: image,
25
+ commands: commands,
26
+ config: options[:config],
27
+ concurrency: options[:concurrency],
28
+ compute_type: options[:compute_type],
29
+ timeout: options[:timeout],
30
+ env: options[:env],
31
+ buildspec: options[:buildspec],
32
+ file_pattern: options[:file_pattern],
33
+ aggregator: options[:aggregator],
34
+ debug: options[:debug],
35
+ access_key: options[:access_key],
36
+ secret_key: options[:secret_key],
37
+ region: options[:region],
38
+ profile_name: options[:profile_name],
39
+ logger: logger)
40
+
41
+ logger.info("Creating source....")
42
+ source = Drunker::Source.new(Pathname.pwd, config: config, logger: logger)
43
+
44
+ logger.info("Starting executor...")
45
+ artifact = Drunker::Executor.new(source: source, config: config, logger: logger).run
46
+
47
+ logger.info("Starting aggregator...")
48
+ aggregator = Drunker::Aggregator.create(config)
49
+ aggregator.run(artifact.layers)
50
+
51
+ unless config.debug?
52
+ logger.info("Deleting source...")
53
+ source.delete
54
+ logger.info("Deleting artifact...")
55
+ artifact.delete
56
+ end
57
+
58
+ exit aggregator.exit_status(artifact.layers)
59
+ rescue Drunker::Config::InvalidConfigException => exn
60
+ logger.error(exn.message)
61
+ exit 1
62
+ end
63
+ map "run" => "_run" # "run" is a Thor reserved word and cannot be defined as command
64
+
65
+ desc "version", "Show version"
66
+ def version
67
+ puts "Drunker #{VERSION}"
68
+ end
69
+
70
+ def self.exit_on_failure?
71
+ true
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,160 @@
1
+ module Drunker
2
+ class Config
3
+ attr_reader :image
4
+ attr_reader :commands
5
+ attr_reader :concurrency
6
+ attr_reader :compute_type
7
+ attr_reader :timeout
8
+ attr_reader :environment_variables
9
+ attr_reader :buildspec
10
+ attr_reader :file_pattern
11
+ attr_reader :aggregator
12
+
13
+ class InvalidConfigException < StandardError; end
14
+
15
+ def initialize(image:,
16
+ commands:,
17
+ config:,
18
+ concurrency:,
19
+ compute_type:,
20
+ timeout:,
21
+ env:,
22
+ buildspec:,
23
+ file_pattern:,
24
+ aggregator:,
25
+ access_key:,
26
+ secret_key:,
27
+ region:,
28
+ profile_name:,
29
+ debug:,
30
+ logger:)
31
+ @logger = logger
32
+ yaml = load!(config)
33
+
34
+ @image = image
35
+ @commands = commands
36
+ @concurrency = yaml["concurrency"] || concurrency
37
+ @compute_type = compute_name[ yaml["compute_type"] || compute_type ]
38
+ @timeout = yaml["timeout"] || timeout
39
+ @environment_variables = codebuild_environments_format(yaml["environment_variables"] || env)
40
+ @buildspec = buildspec_body!(yaml["buildspec"] || buildspec)
41
+ @file_pattern = yaml["file_pattern"] || file_pattern
42
+ @aggregator = aggregator_gem!(yaml["aggregator"] || aggregator)
43
+ @credentials = aws_credentials(profile_name: yaml.dig("aws_credentials", "profile_name") || profile_name,
44
+ access_key: yaml.dig("aws_credentials", "access_key") || access_key,
45
+ secret_key: yaml.dig("aws_credentials", "secret_key") || secret_key)
46
+ @region = yaml.dig("aws_credentials", "region") || region
47
+ @debug = debug
48
+
49
+ validate!
50
+ end
51
+
52
+ def debug?
53
+ debug
54
+ end
55
+
56
+ def aws_client_options
57
+ { credentials: credentials, region: region }.delete_if { |_k, v| v.nil? }
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :credentials
63
+ attr_reader :region
64
+ attr_reader :debug
65
+ attr_reader :logger
66
+
67
+ def compute_name
68
+ {
69
+ "small" => "BUILD_GENERAL1_SMALL",
70
+ "medium" => "BUILD_GENERAL1_MEDIUM",
71
+ "large" => "BUILD_GENERAL1_LARGE"
72
+ }
73
+ end
74
+
75
+ def codebuild_environments_format(env)
76
+ env.map { |k, v| { name: k, value: v } }
77
+ end
78
+
79
+ def buildspec_body!(buildspec)
80
+ if buildspec
81
+ buildspec.is_a?(Hash) ? buildspec.to_yaml : Pathname.new(buildspec).read
82
+ else
83
+ Pathname.new(__dir__ + "/executor/buildspec.yml.erb").read
84
+ end
85
+ rescue Errno::ENOENT
86
+ raise InvalidConfigException.new("Invalid location of custom buildspec. got: #{buildspec}")
87
+ end
88
+
89
+ def aws_credentials(profile_name:, access_key:, secret_key:)
90
+ if profile_name
91
+ Aws::SharedCredentials.new(profile_name: profile_name)
92
+ elsif access_key && secret_key
93
+ Aws::Credentials.new(access_key, secret_key)
94
+ end
95
+ end
96
+
97
+ def aggregator_gem!(name)
98
+ gem = Gem::Specification.select { |gem| gem.name == "drunker-aggregator-#{name}" }.max { |a, b| a.version <=> b.version }
99
+ raise InvalidConfigException.new("Invalid aggregator. `drunker-aggregator-#{name}` is already installed?") unless gem
100
+ gem
101
+ end
102
+
103
+ def load!(config)
104
+ yaml = YAML.load_file(config)
105
+ validate_yaml!(yaml)
106
+ yaml
107
+ rescue Errno::ENOENT
108
+ if config == ".drunker.yml"
109
+ logger.debug("Config file not found. But it ignored because this is default config file.")
110
+ {}
111
+ else
112
+ raise InvalidConfigException.new("Config file not found. got: #{config}")
113
+ end
114
+ rescue Psych::SyntaxError => exn
115
+ raise InvalidConfigException.new("Invalid config file. message: #{exn.message}")
116
+ end
117
+
118
+ def validate_yaml!(yaml)
119
+ valid_toplevel_keys = %w(concurrency compute_type timeout file_pattern environment_variables buildspec aggregator aws_credentials)
120
+ invalid_keys = yaml.keys.reject { |k| valid_toplevel_keys.include?(k) }
121
+ raise InvalidConfigException.new("Invalid config file keys: #{invalid_keys.join(",")}") unless invalid_keys.empty?
122
+
123
+ if yaml["aws_credentials"]
124
+ valid_aws_credentials_keys = %w(profile_name access_key secret_key region)
125
+ invalid_keys = yaml["aws_credentials"].keys.reject { |k| valid_aws_credentials_keys.include?(k) }
126
+ raise InvalidConfigException.new("Invalid config file keys: #{invalid_keys.join(",")}") unless invalid_keys.empty?
127
+ end
128
+
129
+ message = case
130
+ when yaml["concurrency"] && !yaml["concurrency"].is_a?(Numeric)
131
+ "Invalid concurrency. It should be number (Not string). got: #{yaml["concurrency"]}"
132
+ when yaml["compute_type"] && !%w(small medium large).include?(yaml["compute_type"])
133
+ "Invalid compute type. It should be one of small, medium, large. got: #{yaml["compute_type"]}"
134
+ when yaml["timeout"] && !yaml["timeout"].is_a?(Numeric)
135
+ "Invalid timeout. It should be number (Not string). got: #{yaml["timeout"]}"
136
+ when yaml["buildspec"] && !(yaml["buildspec"].is_a?(String) || yaml["buildspec"].is_a?(Hash))
137
+ "Invalid buildspec. It should be string or hash. got: #{yaml["buildspec"]}"
138
+ when yaml["environment_variables"] && !yaml["environment_variables"]&.values.all? { |v| v.is_a?(String) || v.is_a?(Numeric) }
139
+ "Invalid environment variables. It should be flatten hash. got: #{yaml["environment_variables"]}"
140
+ when yaml["file_pattern"] && !yaml["file_pattern"].is_a?(String)
141
+ "Invalid file pattern. It should be string. got: #{yaml["file_pattern"]}"
142
+ when yaml["aggregator"] && !yaml["aggregator"].is_a?(String)
143
+ "Invalid aggregator. It should be string. got: #{yaml["aggregator"]}"
144
+ end
145
+
146
+ raise InvalidConfigException.new(message) if message
147
+ end
148
+
149
+ def validate!
150
+ message = case
151
+ when concurrency <= 0
152
+ "Invalid concurrency. It should be bigger than 0. got: #{concurrency}"
153
+ when !timeout.between?(5, 480)
154
+ "Invalid timeout range. It should be 5 and 480. got: #{timeout}"
155
+ end
156
+
157
+ raise InvalidConfigException.new(message) if message
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,122 @@
1
+ module Drunker
2
+ class Executor
3
+ RETRY_LIMIT = 10
4
+
5
+ def initialize(source:, config:, logger:)
6
+ @project_name = "drunker-executor-#{Time.now.to_i.to_s}"
7
+ @source = source
8
+ logger.info("Creating artifact...")
9
+ @artifact = Drunker::Artifact.new(config: config, logger: logger)
10
+ @config = config
11
+ @client = Aws::CodeBuild::Client.new(config.aws_client_options)
12
+ @builders = []
13
+ @logger = logger
14
+ end
15
+
16
+ def run
17
+ setup_project do
18
+ @builders = parallel_build
19
+
20
+ loop do
21
+ builders.select(&:access_denied?).each do |builder|
22
+ failed_id = builder.build_id
23
+ if builder.retriable?
24
+ build_id = builder.retry
25
+ artifact.replace_build(before: failed_id ,after: build_id)
26
+ end
27
+ end
28
+
29
+ running, finished = builders.partition(&:running?)
30
+ finished.select(&:failed?).each do |failed|
31
+ failed.errors.each do |error|
32
+ logger.warn("Build failed: #{failed.build_id}")
33
+ logger.warn("\tphase_type: #{error[:phase_type]}")
34
+ logger.warn("\tphase_status: #{error[:phase_status]}")
35
+ logger.warn("\tstatus: #{error[:status]}")
36
+ logger.warn("\tmessage: #{error[:message]}")
37
+ end
38
+ end
39
+
40
+ break if running.count.zero?
41
+ logger.info("Waiting builder: #{finished.count}/#{builders.count}")
42
+ sleep 5
43
+ builders.each(&:refresh)
44
+ end
45
+ logger.info("Build is completed!")
46
+ artifact.layers # load artifact layers from S3
47
+ end
48
+
49
+ artifact
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :project_name
55
+ attr_reader :source
56
+ attr_reader :artifact
57
+ attr_reader :config
58
+ attr_reader :client
59
+ attr_reader :builders
60
+ attr_reader :logger
61
+
62
+ def setup_project
63
+ logger.info("Creating IAM resources...")
64
+ iam = IAM.new(source: source, artifact: artifact, config: config, logger: logger)
65
+ retry_count = 0
66
+
67
+ logger.info("Creating project...")
68
+ project_info = {
69
+ name: project_name,
70
+ source: source.to_h,
71
+ artifacts: artifact.to_h,
72
+ environment: {
73
+ type: "LINUX_CONTAINER",
74
+ image: config.image,
75
+ compute_type: config.compute_type,
76
+ },
77
+ service_role: iam.role.name,
78
+ timeout_in_minutes: config.timeout,
79
+ }
80
+ project_info[:environment][:environment_variables] = config.environment_variables unless config.environment_variables.empty?
81
+ begin
82
+ client.create_project(project_info)
83
+ logger.info("Created project: #{project_name}")
84
+ # Sometimes `CodeBuild is not authorized to perform: sts:AssumeRole` error occurs...
85
+ # We can solve this problem by retrying after a while.
86
+ rescue Aws::CodeBuild::Errors::InvalidInputException
87
+ if retry_count < RETRY_LIMIT
88
+ retry_count += 1
89
+ sleep 5
90
+ logger.info("Retrying...")
91
+ retry
92
+ else
93
+ raise
94
+ end
95
+ end
96
+
97
+ yield
98
+
99
+ unless config.debug?
100
+ logger.info("Deleting IAM resources...")
101
+ iam.delete
102
+ client.delete_project(name: project_name)
103
+ logger.info("Deleted project: #{project_name}")
104
+ end
105
+ end
106
+
107
+ def parallel_build
108
+ builders = []
109
+
110
+ files_list = source.target_files.each_slice(source.target_files.count.quo(config.concurrency).ceil).to_a
111
+ logger.info("Start parallel build: { files: #{source.target_files.count}, concurrency: #{config.concurrency}, real_concurrency: #{files_list.count} }")
112
+ files_list.to_a.each do |files|
113
+ builder = Builder.new(project_name: project_name, targets: files, artifact: artifact, config: config, logger: logger)
114
+ build_id = builder.run
115
+ artifact.set_build(build_id)
116
+ builders << builder
117
+ end
118
+
119
+ builders
120
+ end
121
+ end
122
+ end