drunker 0.1.0

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