chbuild 0.0.2

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,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'chbuild'
6
+
7
+ require 'pry'
8
+ Pry.start
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,45 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+ lib = File.expand_path('../lib', __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require_relative 'lib/chbuild/version.rb'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'chbuild'
9
+ spec.version = CHBuild::VERSION
10
+ spec.author = 'Cheppers Ltd.'
11
+ spec.email = 'info@cheppers.com'
12
+ spec.summary = 'Cheppers Build Tool'
13
+ spec.homepage = 'https://github.com/Cheppers/chbuild'
14
+ spec.license = 'MIT'
15
+ spec.description = <<-EOS
16
+ chbuild is an open-source command line application written in Ruby.
17
+ It's main purpose is to simplify web app development by running a site
18
+ using Docker containers so you don't have to install Apache, PHP and
19
+ PHP extensions on your own machine.
20
+ EOS
21
+
22
+ spec.required_ruby_version = '>= 2.3.0'
23
+
24
+ if spec.respond_to?(:metadata)
25
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
26
+ else
27
+ raise 'RubyGems 2.0 or newer is required to protect against public gem pushes.'
28
+ end
29
+
30
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec)/}) }
31
+ spec.bindir = 'bin'
32
+ spec.executables = ['chbuild']
33
+ spec.require_paths = ['lib']
34
+
35
+ spec.add_dependency 'colorize', '~> 0.8'
36
+ spec.add_dependency 'excon', '~> 0.46', '>= 0.46'
37
+ spec.add_dependency 'docker-api', '~> 1.31', '>= 1.31'
38
+ spec.add_dependency 'thor', '~> 0.19'
39
+
40
+ spec.add_development_dependency 'bundler', '~> 1.12'
41
+ spec.add_development_dependency 'pry', '~> 0.10'
42
+ spec.add_development_dependency 'rake', '~> 10.0'
43
+ spec.add_development_dependency 'rubocop', '~> 0.40'
44
+ spec.add_development_dependency 'rspec', '~> 3.0'
45
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'chbuild/version'
3
+
4
+ # require application files
5
+ require_relative 'chbuild/bindable_hash'
6
+ require_relative 'chbuild/constants'
7
+ require_relative 'chbuild/controller'
8
+ require_relative 'chbuild/config'
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ # CHBuild module :D
3
+ module CHBuild
4
+ # BindableHash is for ERB templates
5
+ class BindableHash
6
+ def initialize(hash)
7
+ hash.each do |key, value|
8
+ singleton_class.send(:define_method, key) { value }
9
+ end
10
+ end
11
+
12
+ def get_binding # rubocop:disable Style/AccessorMethodName
13
+ binding
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ require 'chbuild/config/before'
3
+ require 'chbuild/config/env'
4
+ require 'chbuild/config/errors'
5
+ require 'chbuild/config/use'
6
+ require 'chbuild/config/version'
7
+
8
+ require 'yaml'
9
+
10
+ module CHBuild
11
+ # Build configuration object
12
+ class Config
13
+ attr_reader :path, :raw, :version, :use, :env, :before
14
+
15
+ def initialize(path_to_config)
16
+ begin
17
+ @path = path_to_config
18
+ build_config = YAML.load_file(path_to_config)
19
+ rescue Errno::ENOENT
20
+ raise CHBuild::Config::NotFoundError, "'#{path_to_config}': file not found"
21
+ end
22
+
23
+ @errors = []
24
+ unless build_config
25
+ @errors << 'Build file is empty'
26
+ return
27
+ end
28
+
29
+ @raw = build_config
30
+ @version = Version.new(build_config['version'])
31
+ @use = Use.new(build_config['use'])
32
+ @env = Env.new(build_config['env'])
33
+ @before = Before.new(build_config['before'])
34
+ end
35
+
36
+ def init_script
37
+ unless @init_script
38
+ generation_time = "echo \"Generated at: [#{Time.now}]\"\n\n"
39
+ env_script = @env.to_bash_script
40
+ before_script = @before.to_bash_script
41
+ @init_script = generation_time + env_script + before_script
42
+ end
43
+ @init_script
44
+ end
45
+
46
+ def errors
47
+ all_errors = Array.new(@errors)
48
+
49
+ instance_variables.each do |var|
50
+ section = instance_variable_get(var)
51
+ next unless section.respond_to?(:name) && section.respond_to?(:errors)
52
+ section_name = section.send(:name)
53
+ section_errors = section.send(:errors)
54
+ section_errors.each do |err|
55
+ all_errors << "#{section_name}: #{err}"
56
+ end
57
+ end
58
+
59
+ all_errors
60
+ end
61
+
62
+ def inspect
63
+ data = []
64
+ instance_variables.each do |var|
65
+ section = instance_variable_get(var)
66
+ if section.respond_to?(:name)
67
+ section_name = section.send(:name)
68
+ data << "#{section_name}: #{section.inspect}"
69
+ end
70
+ end
71
+ data.join("\n")
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ module CHBuild
3
+ class Config
4
+ # Before section
5
+ class Before < Array
6
+ # before_commands is required so no default value
7
+ def initialize(before_commands)
8
+ super([])
9
+ validate!(before_commands)
10
+ replace(before_commands) unless before_commands.nil?
11
+ end
12
+
13
+ def validate!(before_commands)
14
+ @errors = []
15
+ @errors << 'Required' if before_commands.nil?
16
+ @errors << 'Cannot be empty' if before_commands == []
17
+ end
18
+
19
+ attr_reader :errors
20
+
21
+ def name
22
+ "Section 'before'"
23
+ end
24
+
25
+ def to_bash_script
26
+ reduce('') { |a, e| a + "#{e}\n" }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ module CHBuild
3
+ class Config
4
+ # Env section
5
+ class Env < Hash
6
+ def initialize(env = {})
7
+ validate!(env)
8
+ super
9
+ replace(env) unless env.nil?
10
+ end
11
+
12
+ def validate!(_env)
13
+ true
14
+ end
15
+
16
+ def errors
17
+ []
18
+ end
19
+
20
+ def name
21
+ "Section 'env'"
22
+ end
23
+
24
+ def to_bash_script
25
+ reduce('') { |a, (k, v)| a + "export #{k}=\"#{v}\"\n" }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+ module CHBuild
3
+ class Config
4
+ # NotFoundError
5
+ class NotFoundError < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'chbuild/constants'
4
+
5
+ module CHBuild
6
+ class Config
7
+ # Use section
8
+ class Use < Hash
9
+ DEFAULTS ||= {
10
+ 'php' => CHBuild::DEFAULT_PHP_VERSION,
11
+ 'mysql' => CHBuild::DEFAULT_MYSQL_VERSION
12
+ }.freeze
13
+
14
+ ALLOWED_VALUES ||= DEFAULTS.keys
15
+
16
+ def initialize(using = {})
17
+ super
18
+ replace(DEFAULTS)
19
+
20
+ validate!(using)
21
+
22
+ merge!(using) unless using.nil?
23
+ end
24
+
25
+ def validate!(using)
26
+ @errors = []
27
+ if using.respond_to? :keys
28
+ extra_keys = using.keys - ALLOWED_VALUES
29
+ extra_keys.each do |key|
30
+ @errors << "Unknown key: #{key}"
31
+ end
32
+ end
33
+ end
34
+
35
+ attr_reader :errors
36
+
37
+ def name
38
+ "Section 'use'"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+ module CHBuild
3
+ class Config
4
+ # Version section
5
+ class Version
6
+ ALLOWED_VALUES ||= [1.0].freeze
7
+
8
+ # version is required so no default value
9
+ def initialize(version)
10
+ validate!(version)
11
+ @yaml_version = version
12
+ end
13
+
14
+ def inspect
15
+ @yaml_version.to_s
16
+ end
17
+
18
+ def validate!(version)
19
+ @errors = []
20
+ if version.nil?
21
+ @errors << 'Required'
22
+ return
23
+ end
24
+ @errors << "Unknown value: '#{version}'" unless ALLOWED_VALUES.include? version
25
+ end
26
+
27
+ attr_reader :errors
28
+
29
+ def name
30
+ "Section 'version'"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ # CHBuild main module
6
+ module CHBuild
7
+ GEM_ROOT = File.expand_path(File.join(File.dirname(__FILE__), '../../'))
8
+ TEMPLATE_DIR = "#{CHBuild::GEM_ROOT}/templates"
9
+ DEFAULT_DOMAIN = 'cheppers.com'
10
+
11
+ IMAGE_NAME ||= 'chbuild'
12
+ DOCKER_DIR ||= 'docker'
13
+ DEFAULT_PHP_VERSION = '5.6'
14
+ DEFAULT_MYSQL_VERSION = 'latest'
15
+
16
+ DEFAULT_OPTS ||= {
17
+ refreshed_at_date: ::Date.today.strftime('%Y-%m-%d'),
18
+ php_timezone: 'Europe/Budapest',
19
+ php_memory_limit: '256M',
20
+ max_upload: '50M',
21
+ php_max_file_upload: 200,
22
+ php_max_post: '100M',
23
+ extra_files: CHBuild::TEMPLATE_DIR
24
+ }.freeze
25
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+ require 'docker-api'
3
+ require 'erb'
4
+
5
+ require 'chbuild/bindable_hash'
6
+ require 'chbuild/config'
7
+ require 'chbuild/utils'
8
+
9
+ # rubocop:disable Metrics/ClassLength, Lint/AssignmentInCondition
10
+
11
+ # CHBuild main module
12
+ module CHBuild
13
+ # CHBuild::Controller
14
+ class Controller
15
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
16
+ def self.config(path_to_config = nil)
17
+ # rubocop:disable Style/ClassVars
18
+ @@config ||= nil
19
+
20
+ unless @@config
21
+ @@config = if path_to_config.nil?
22
+ CHBuild::Config.new(File.join(Dir.pwd, '.chbuild.yml'))
23
+ else
24
+ CHBuild::Config.new(path_to_config)
25
+ end
26
+ end
27
+ @@config
28
+ end
29
+
30
+ def self.image_exist?
31
+ Docker::Image.exist? CHBuild::IMAGE_NAME
32
+ end
33
+
34
+ def self.container?
35
+ Docker::Container.all(
36
+ 'filters' => { 'ancestor' => [CHBuild::IMAGE_NAME] }.to_json, 'all' => true
37
+ ).first
38
+ rescue
39
+ nil
40
+ end
41
+
42
+ def self.promote
43
+ puts '!!! WIP !!!'
44
+ puts "FQDN: #{CHBuild::Utils.fqdn}"
45
+ end
46
+
47
+ def self.build
48
+ template = load_docker_template(
49
+ 'base-docker-container',
50
+ php_memory_limit: '128M'
51
+ )
52
+
53
+ docker_context = generate_docker_archive(template)
54
+
55
+ Docker::Image.build_from_tar(docker_context, t: CHBuild::IMAGE_NAME) do |r|
56
+ r.each_line do |log|
57
+ if (message = JSON.parse(log)) && message.key?('stream')
58
+ yield message['stream'] if block_given?
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ # rubocop:disable Metrics/PerceivedComplexity, Lint/UnusedMethodArgument, Metrics/MethodLength
65
+ def self.run(config_path: nil, webroot: nil, initscripts: nil)
66
+ unless image_exist?
67
+ yield "Image doesn't exist" if block_given?
68
+ return false
69
+ end
70
+
71
+ bind_volumes = []
72
+
73
+ if !webroot.nil? && Dir.exist?(File.expand_path(webroot))
74
+ bind_volumes << "#{webroot}:/www"
75
+ end
76
+ if !initscripts.nil? && Dir.exist?(File.expand_path(initscripts))
77
+ bind_volumes << "#{initscripts}:/initscripts"
78
+ end
79
+
80
+ if c = container?
81
+ yield 'Removing existing chbuild container...' if block_given?
82
+ c.remove(force: true)
83
+ end
84
+
85
+ mysql_container_version = "mysql:#{config.use['mysql']}"
86
+ unless Docker::Image.exist? mysql_container_version
87
+ yield "Downloading #{mysql_container_version}..." if block_given?
88
+ Docker::Image.create('fromImage' => mysql_container_version)
89
+ end
90
+
91
+ mysql_container_name = mysql_container_version.tr(':.', '_')
92
+
93
+ stop_mysql_containers(except: mysql_container_version)
94
+ begin
95
+ mysql_container = Docker::Container.get(mysql_container_name)
96
+ rescue Docker::Error::NotFoundError
97
+ yield 'Creating MySQL container...' if block_given?
98
+ mysql_container = Docker::Container.create(
99
+ 'name' => mysql_container_name,
100
+ 'Image' => mysql_container_version,
101
+ 'Env' => ['MYSQL_ROOT_PASSWORD=admin']
102
+ )
103
+ ensure
104
+ mysql_container_info = Docker::Container.get(mysql_container.id).info
105
+ unless mysql_container_info['State']['Running']
106
+ yield 'Starting MySQL container...' if block_given?
107
+ mysql_container.start!
108
+ sleep 10
109
+ end
110
+ end
111
+ mysql_container_info = Docker::Container.get(mysql_container.id).info
112
+ unless mysql_container_info['State']['Running']
113
+ yield "Couldn't start MySQL container" if block_given?
114
+ return false
115
+ end
116
+ yield "MySQL container: #{mysql_container.id[0, 10]}" if block_given?
117
+
118
+ yield 'Creating CHBuild container...' if block_given?
119
+ container = Docker::Container.create(
120
+ 'name' => CHBuild::Utils.generate_conatiner_name,
121
+ 'Image' => CHBuild::IMAGE_NAME,
122
+ 'Cmd' => ['/init.sh'],
123
+ 'Volumes' => {
124
+ "#{Dir.pwd}/webroot" => {},
125
+ "#{Dir.pwd}/initscripts" => {}
126
+ },
127
+ 'ExposedPorts' => {
128
+ '80/tcp' => {}
129
+ },
130
+ 'HostConfig' => {
131
+ 'Links' => ["#{mysql_container_name}:mysql"],
132
+ 'PortBindings' => {
133
+ '80/tcp' => [{ 'HostPort' => '8088' }]
134
+ },
135
+ 'Binds' => bind_volumes
136
+ }
137
+ )
138
+
139
+ container.store_file('/init.sh', content: load_init_script, permissions: 0777)
140
+
141
+ container.start!
142
+ end
143
+
144
+ def self.delete_container
145
+ if c = container?
146
+ c.delete(force: true)
147
+ true
148
+ else
149
+ false
150
+ end
151
+ end
152
+
153
+ def self.container_id
154
+ if c = container?
155
+ c.id[0, 10]
156
+ end
157
+ end
158
+
159
+ def self.container_logs
160
+ if c = container?
161
+ c.logs(stdout: true)
162
+ end
163
+ end
164
+
165
+ def self.delete_image
166
+ if image_exist?
167
+ image = Docker::Image.get CHBuild::IMAGE_NAME
168
+ image.remove(force: true)
169
+ end
170
+ end
171
+
172
+ def self.mysql_containers
173
+ Docker::Container.all.select { |c| c.info['Image'].start_with?('mysql') }
174
+ end
175
+
176
+ def self.delete_mysql_containers
177
+ if mysql_containers.empty?
178
+ false
179
+ else
180
+ mysql_containers.each { |c| c.delete(force: true) }
181
+ true
182
+ end
183
+ end
184
+
185
+ def self.stop_mysql_containers(except: "\n")
186
+ mysql_containers.select { |c| !c.info['Image'].end_with?(except) }.each(&:stop)
187
+ end
188
+
189
+ private_class_method
190
+
191
+ def self.load_docker_template(template_name, opts = {})
192
+ opts = CHBuild::DEFAULT_OPTS.merge(opts)
193
+
194
+ context = BindableHash.new opts
195
+ ::ERB.new(
196
+ File.read("#{CHBuild::TEMPLATE_DIR}/#{template_name}.erb")
197
+ ).result(context.get_binding)
198
+ end
199
+
200
+ def self.load_init_script
201
+ context = BindableHash.new(before_script: config.init_script)
202
+ ::ERB.new(
203
+ File.read("#{CHBuild::TEMPLATE_DIR}/init.sh.erb")
204
+ ).result(context.get_binding)
205
+ end
206
+
207
+ def self.generate_docker_archive(dockerfile_content)
208
+ tar = StringIO.new
209
+
210
+ Gem::Package::TarWriter.new(tar) do |writer|
211
+ writer.add_file('Dockerfile', 0644) { |f| f.write(dockerfile_content) }
212
+ end
213
+
214
+ compress_archive(tar)
215
+ end
216
+
217
+ def self.compress_archive(tar)
218
+ tar.seek(0)
219
+ # rubocop:disable Style/EmptyLiteral
220
+ gz = StringIO.new(String.new, 'r+b').set_encoding(Encoding::BINARY)
221
+ gz_writer = Zlib::GzipWriter.new(gz)
222
+ gz_writer.write(tar.read)
223
+ tar.close
224
+ gz_writer.finish
225
+ gz.rewind
226
+
227
+ gz
228
+ end
229
+ end
230
+ end