php_fpm_docker 0.0.1

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,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