php_fpm_docker 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,309 @@
1
+ # coding: utf-8
2
+ require 'pathname'
3
+ require 'inifile'
4
+ require 'pp'
5
+ require 'logger'
6
+ require 'docker'
7
+ require 'digest'
8
+
9
+ module PhpFpmDocker
10
+ # Represent a single docker image
11
+ class Launcher # rubocop:disable ClassLength
12
+ attr_reader :docker_image, :php_cmd_path, :spawn_cmd_path
13
+
14
+ def initialize(name) # rubocop:disable MethodLength
15
+ @name = name
16
+
17
+ # Create log dir if needed
18
+ log_dir = Pathname.new('/var/log/php_fpm_docker')
19
+ FileUtils.mkdir_p log_dir unless log_dir.directory?
20
+
21
+ # Open logger
22
+ @logger = Logger.new(log_dir.join("#{name}.log"), 'daily')
23
+ @logger.info(to_s) { 'init' }
24
+
25
+ test
26
+ end
27
+
28
+ def test
29
+ test_directories
30
+
31
+ # Parse config
32
+ parse_config
33
+
34
+ # Test docker image
35
+ test_docker_image
36
+
37
+ rescue RuntimeError => e
38
+ @logger.fatal(to_s) { "Error while init: #{e.message}" }
39
+ exit 1
40
+ end
41
+
42
+ def run
43
+ start_pools
44
+
45
+ pid = fork do
46
+ fork_run
47
+ end
48
+ Process.detach(pid)
49
+ pid
50
+ end
51
+
52
+ def fork_run
53
+ Signal.trap('USR1') do
54
+ @logger.info(to_s) { 'Signal USR1 received reloading now' }
55
+ reload_pools
56
+ end
57
+ Signal.trap('TERM') do
58
+ @logger.info(to_s) { 'Signal TERM received stopping me now' }
59
+ stop_pools
60
+ exit 0
61
+ end
62
+ Kernel.loop do
63
+ check_pools
64
+ sleep 1
65
+ end
66
+ end
67
+
68
+ def start_pools
69
+ @pools = {}
70
+ reload_pools
71
+ end
72
+
73
+ def stop_pools
74
+ reload_pools({})
75
+ end
76
+
77
+ def create_missing_pool_objects
78
+ return if @pools.nil?
79
+ return if @pools_old.nil?
80
+ (@pools.keys & @pools_old.keys).each do |hash|
81
+ @pools[hash][:object] = @pools_old[hash][:object]
82
+ end
83
+ end
84
+
85
+ def move_existing_pool_objects
86
+ return if @pools.nil?
87
+ @pools.keys.each do |hash|
88
+ pool = @pools[hash]
89
+ # skip if there's already an object
90
+ next if pool.key?(:object)
91
+ pool[:object] = Pool.new(
92
+ config: pool[:config],
93
+ name: pool[:name],
94
+ launcher: self
95
+ )
96
+ end
97
+ end
98
+
99
+ def reload_pools(pools = nil)
100
+ @pools_old = @pools
101
+ if pools.nil?
102
+ @pools = pools_from_config
103
+ else
104
+ @pools = pools
105
+ end
106
+
107
+ move_existing_pool_objects
108
+ create_missing_pool_objects
109
+
110
+ # Pools to stop
111
+ pools_action(@pools_old, @pools_old.keys - @pools.keys, :stop)
112
+
113
+ # Pools to start
114
+ pools_action(@pools, @pools.keys - @pools_old.keys, :start)
115
+ end
116
+
117
+ def check_pools
118
+ 'do nothing'
119
+ end
120
+
121
+ def check_pools_n
122
+ pools_action(@pools, @pools.keys, :check)
123
+ end
124
+
125
+ def pools_action(pools, pools_hashes, action)
126
+ message = ''
127
+ if pools_hashes.length > 0
128
+ message << "Pools to #{action}: "
129
+ message << pools_hashes.map { |p| pools[p][:name] }.join(', ')
130
+ pools_hashes.each do |pool_hash|
131
+ pool = pools[pool_hash]
132
+ begin
133
+ pool[:object].send(action)
134
+ rescue => e
135
+ @logger.warn(pool[:object].to_s) do
136
+ "Failed to #{action}: #{e.message}"
137
+ end
138
+ end
139
+ end
140
+ else
141
+ message << "No pools to #{action}"
142
+ end
143
+ @logger.info(to_s) { message }
144
+ end
145
+
146
+ def test_directories
147
+ # Get config dirs and paths
148
+ @conf_directory = Pathname.new('/etc/php_fpm_docker/conf.d').join(@name)
149
+ fail "Config directory '#{@conf_directory}' not found" \
150
+ unless @conf_directory.directory?
151
+
152
+ @pools_directory = @conf_directory.join('pools.d')
153
+ fail "Pool directory '#{@pools_directory}' not found" \
154
+ unless @pools_directory.directory?
155
+
156
+ @config_path = @conf_directory.join('config.ini')
157
+ end
158
+
159
+ def to_s
160
+ "<Launcher:#{@name}>"
161
+ end
162
+
163
+ # Get neccessary bind mounts
164
+ def bind_mounts
165
+ @ini_file[:main]['bind_mounts'].split(',') || []
166
+ end
167
+
168
+ # Get webs base path
169
+ def web_path
170
+ Pathname.new(@ini_file[:main]['web_path'] || '/var/www')
171
+ end
172
+
173
+ # Parse the config file for all pools
174
+ def parse_config # rubocop:disable MethodLength
175
+ # Test for file usability
176
+ fail "Config file '#{@config_path}' not found"\
177
+ unless @config_path.file?
178
+ fail "Config file '#{@config_path}' not readable"\
179
+ unless @config_path.readable?
180
+
181
+ @ini_file = IniFile.load(@config_path)
182
+
183
+ begin
184
+ docker_image = @ini_file[:main]['docker_image']
185
+ @docker_image = Docker::Image.get(docker_image)
186
+ @logger.info(to_s) do
187
+ "Docker image id=#{@docker_image.id[0..11]} name=#{docker_image}"
188
+ end
189
+ rescue NoMethodError
190
+ raise 'No docker_image in section main in config found'
191
+ rescue Docker::Error::NotFoundError
192
+ raise "Docker_image '#{docker_image}' not found"
193
+ rescue Excon::Errors::SocketError => e
194
+ raise "Docker connection could not be established: #{e.message}"
195
+ end
196
+ end
197
+
198
+ def docker_opts
199
+ {
200
+ 'Image' => @docker_image.id
201
+ }
202
+ end
203
+
204
+ # Reads config sections from a inifile
205
+ def pools_config_content_from_file(config_path)
206
+ ini_file = IniFile.load(config_path)
207
+
208
+ ret_val = []
209
+ ini_file.each_section do |section|
210
+ ret_val << [section, ini_file[section]]
211
+ end
212
+ ret_val
213
+ end
214
+
215
+ # Merges config sections form all inifiles
216
+ def pools_config_contents
217
+ ret_val = []
218
+
219
+ # Loop over
220
+ Dir[@pools_directory.join('*.conf').to_s].each do |config_path|
221
+ ret_val += pools_config_content_from_file(config_path)
222
+ end
223
+ ret_val
224
+ end
225
+
226
+ # Hashes configs to detect changes
227
+ def pools_from_config
228
+ configs = {}
229
+
230
+ pools_config_contents.each do |section|
231
+ # Hash section name and content
232
+ d = Digest::SHA2.new(256)
233
+ hash = d.reset.update(section[0]).update(section[1].to_s).to_s
234
+
235
+ configs[hash] = {
236
+ name: section[0],
237
+ config: section[1]
238
+ }
239
+ end
240
+ configs
241
+ end
242
+
243
+ # Docker init
244
+ def test_docker_cmd(cmd) # rubocop:disable MethodLength
245
+ # retry this block 3 times
246
+ tries ||= 3
247
+
248
+ opts = docker_opts
249
+ opts['Cmd'] = cmd
250
+ dict = {}
251
+
252
+ # Set timeout
253
+ Docker.options[:read_timeout] = 2
254
+
255
+ cont = Docker::Container.create(opts)
256
+ cont.start
257
+ output = cont.attach
258
+ dict[:ret_val] = cont.wait(5)['StatusCode']
259
+ cont.delete(force: true)
260
+
261
+ dict[:stdout] = output[0].first
262
+ dict[:stderr] = output[1].first
263
+
264
+ # Set timeout
265
+ Docker.options[:read_timeout] = 15
266
+
267
+ @logger.debug(to_s) do
268
+ "cmd=#{cmd.join(' ')} ret_val=#{dict[:ret_val]}" \
269
+ " stdout=#{dict[:stdout]} stderr=#{dict[:stderr]}"
270
+ end
271
+
272
+ dict
273
+ rescue Docker::Error::TimeoutError => e
274
+ if (tries -= 1) > 0
275
+ cont.delete(force: true) if cont.nil?
276
+ @logger.debug(to_s) { 'ran into timeout retry' }
277
+ retry
278
+ end
279
+ raise e
280
+ end
281
+
282
+ # Testing the docker image if i can be used
283
+ def test_docker_image # rubocop:disable MethodLength
284
+ # Test possible php commands
285
+ ['php-cgi', 'php5-cgi', 'php', 'php5'].each do |php_cmd|
286
+ result = test_docker_cmd [:which, php_cmd]
287
+
288
+ next unless result[:ret_val] == 0
289
+
290
+ php_cmd_path = result[:stdout].strip
291
+
292
+ result = test_docker_cmd [php_cmd_path, '-v']
293
+
294
+ next unless result[:ret_val] == 0
295
+ php_version_re = /PHP [A-Za-z0-9\.\-\_]+ \(cgi-fcgi\)/
296
+ next if php_version_re.match(result[:stdout]).nil?
297
+
298
+ @php_cmd_path = php_cmd_path
299
+ break
300
+ end
301
+ fail 'No usable fast-cgi enabled php found in image' if @php_cmd_path.nil?
302
+
303
+ # Test if spawn-fcgi exists
304
+ result = test_docker_cmd [:which, 'spawn-fcgi']
305
+ fail 'No usable spawn-fcgi found in image' unless result[:ret_val] == 0
306
+ @spawn_cmd_path = result[:stdout].strip
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,169 @@
1
+ # coding: utf-8
2
+ require 'pp'
3
+ require 'inifile'
4
+ require 'docker'
5
+ require 'securerandom'
6
+
7
+ module PhpFpmDocker
8
+ # A pool represent a single isolated PHP web instance
9
+ class Pool
10
+ attr_reader :enabled
11
+ def initialize(opts)
12
+ @config = opts[:config]
13
+ @launcher = opts[:launcher]
14
+ @name = opts[:name]
15
+ end
16
+
17
+ def docker_create_opts
18
+ volumes = {}
19
+ bind_mounts.each do |d|
20
+ volumes[d] = {}
21
+ end
22
+
23
+ {
24
+ 'name' => container_name,
25
+ 'Image' => @launcher.docker_image.id,
26
+ 'Volumes' => volumes,
27
+ 'WorkingDir' => '/'
28
+ }
29
+ end
30
+
31
+ def docker_start_opts
32
+ binds = bind_mounts.map do |d|
33
+ "#{d}:#{d}"
34
+ end
35
+ {
36
+ 'Binds' => binds
37
+ }
38
+ end
39
+
40
+ # Return web path regexs
41
+ def web_path_regex
42
+ [
43
+ %r{(^#{@launcher.web_path}/clients/client\d+/web\d+)},
44
+ %r{(^#{@launcher.web_path}/[^/]+)/web$}
45
+ ]
46
+ end
47
+
48
+ def valid_web_paths
49
+ ret_val = []
50
+ open_base_dirs.map do |dir|
51
+ web_path_regex.each do |regex|
52
+ m = regex.match(dir)
53
+ ret_val << m[1] unless m.nil?
54
+ end
55
+ end
56
+ ret_val
57
+ end
58
+
59
+ # Find out bind mount paths
60
+ def bind_mounts
61
+ ret_val = @launcher.bind_mounts
62
+ ret_val << File.dirname(@config['listen'])
63
+ ret_val += valid_web_paths
64
+ ret_val.uniq
65
+ end
66
+
67
+ def open_base_dirs
68
+ @config['php_admin_value[open_basedir]'].split(':')
69
+ end
70
+
71
+ def root_dir
72
+ max(valid_web_paths)
73
+ end
74
+
75
+ def socket_dir
76
+ File.dirname(@config['listen'])
77
+ end
78
+
79
+ def uid_from_user(user)
80
+ Etc.getpwnam(user).uid
81
+ end
82
+
83
+ def gid_from_group(group)
84
+ Etc.getgrnam(group).gid
85
+ end
86
+
87
+ def listen_uid
88
+ uid_from_user(@config['listen.owner'])
89
+ end
90
+
91
+ def listen_gid
92
+ gid_from_group(@config['listen.group'])
93
+ end
94
+
95
+ def uid
96
+ uid_from_user(@config['user'])
97
+ end
98
+
99
+ def gid
100
+ gid_from_group(@config['group'])
101
+ end
102
+
103
+ # Build the spawn command
104
+ def spawn_command
105
+ [
106
+ @launcher.spawn_cmd_path,
107
+ '-s', @config['listen'],
108
+ '-U', listen_uid.to_s,
109
+ '-G', listen_gid.to_s,
110
+ '-M', '0660',
111
+ '-u', uid.to_s,
112
+ '-g', gid.to_s,
113
+ '-C', '4',
114
+ '-n'
115
+ ]
116
+ end
117
+
118
+ def php_command
119
+ admin_options = []
120
+ @config.each_key do |key|
121
+ m = /^php_admin_value\[([^\]]+)\]$/.match(key)
122
+ next if m.nil?
123
+
124
+ admin_options << '-d'
125
+ admin_options << "#{m[1]}=#{@config[key]}"
126
+
127
+ end
128
+
129
+ [@launcher.php_cmd_path] + admin_options
130
+ end
131
+
132
+ def start
133
+ @enabled = true
134
+ create_opts = docker_create_opts
135
+ create_opts['Cmd'] = spawn_command + ['--'] + php_command
136
+
137
+ @container = Docker::Container.create(create_opts)
138
+ @container.start(docker_start_opts)
139
+ end
140
+
141
+ def container_name
142
+ @container_name ||= "#{@name}_#{SecureRandom.hex[0..11]}"
143
+ end
144
+
145
+ def container_running?
146
+ return false if @container.nil?
147
+ begin
148
+ return @container.info['State']['Running']
149
+ rescue NoMethodError
150
+ return false
151
+ end
152
+ end
153
+
154
+ def check
155
+ return unless @enabled && !container_running?
156
+ stop
157
+ start
158
+ end
159
+
160
+ def stop
161
+ @enabled = false
162
+ @container.delete(force: true) unless @container.nil?
163
+ end
164
+
165
+ def to_s
166
+ "<Pool:#{@name}>"
167
+ end
168
+ end
169
+ end