more comments; changed superuser name
[slapos.git] / slapos / recipe / postgres / __init__.py
1 ##############################################################################
2 #
3 # Copyright (c) 2010 Vifib SARL and Contributors. All Rights Reserved.
4 #
5 # WARNING: This program as such is intended to be used by professional
6 # programmers who take the whole responsibility of assessing all potential
7 # consequences resulting from its eventual inadequacies and bugs
8 # End users who are looking for a ready-to-use solution with commercial
9 # guarantees and support are strongly adviced to contract a Free Software
10 # Service Company
11 #
12 # This program is Free Software; you can redistribute it and/or
13 # modify it under the terms of the GNU General Public License
14 # as published by the Free Software Foundation; either version 3
15 # of the License, or (at your option) any later version.
16 #
17 # This program is distributed in the hope that it will be useful,
18 # but WITHOUT ANY WARRANTY; without even the implied warranty of
19 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 # GNU General Public License for more details.
21 #
22 # You should have received a copy of the GNU General Public License
23 # along with this program; if not, write to the Free Software
24 # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
25 #
26 ##############################################################################
27
28 import md5
29 import os
30 import subprocess
31 import textwrap
32 from zc.buildout import UserError
33
34 from slapos.recipe.librecipe import GenericBaseRecipe
35
36
37
38 class Recipe(GenericBaseRecipe):
39 """\
40 This recipe creates:
41
42 - a Postgres cluster
43 - configuration to allow connections from IPv4, IPv6 or unix socket.
44 - a superuser with provided name and generated password
45 - a database with provided name
46 - a start script in the services directory
47
48 Required options:
49 bin
50 path to the 'initdb' and 'postgres' binaries.
51 dbname
52 name of the database to be used by the application.
53 ipv4
54 set of ipv4 to listen on.
55 ipv6
56 set of ipv6 to listen on.
57 pgdata-directory
58 path to postgres configuration and data.
59 services
60 must be ${buildout:directory}/etc/service.
61 superuser
62 name of the superuser to create.
63
64 Exposed options:
65 password
66 generated password for the superuser.
67 url
68 generated DBAPI connection string.
69 it can be used as-is (ie. in sqlalchemy) or by the _urlparse.py recipe.
70 """
71
72 def _options(self, options):
73 options['password'] = self.generatePassword()
74 options['url'] = 'postgresql://%(superuser)s:%(password)s@[%(ipv6_random)s]:%(port)s/%(dbname)s' % options
75
76
77 def install(self):
78 pgdata = self.options['pgdata-directory']
79
80 # if the pgdata already exists, skip all steps, we don't need to do anything.
81
82 if not os.path.exists(pgdata):
83 self.createCluster()
84 self.createConfig()
85 self.createDatabase()
86 self.updateSuperuser()
87 self.createRunScript()
88
89 # install() methods usually return the pathnames of managed files.
90 # If they are missing, they will be rebuilt.
91 # In this case, we already check for the existence of pgdata,
92 # so we don't need to return anything here.
93
94 return []
95
96
97 def check_exists(self, path):
98 if not os.path.isfile(path):
99 raise IOError('File not found: %s' % path)
100
101
102 def createCluster(self):
103 """\
104 A Postgres cluster is "a collection of databases that is managed
105 by a single instance of a running database server".
106
107 Here we create an empty cluster.
108 """
109 initdb_binary = os.path.join(self.options['bin'], 'initdb')
110 self.check_exists(initdb_binary)
111
112 pgdata = self.options['pgdata-directory']
113
114 try:
115 subprocess.check_call([initdb_binary,
116 '-D', pgdata,
117 '-A', 'ident',
118 '-E', 'UTF8',
119 '-U', self.options['superuser'],
120 ])
121 except subprocess.CalledProcessError:
122 raise UserError('Could not create cluster directory in %s' % pgdata)
123
124
125 def createConfig(self):
126 pgdata = self.options['pgdata-directory']
127 ipv4 = self.options['ipv4']
128 ipv6 = self.options['ipv6']
129
130 with open(os.path.join(pgdata, 'postgresql.conf'), 'wb') as cfg:
131 cfg.write(textwrap.dedent("""\
132 listen_addresses = '%s'
133 logging_collector = on
134 log_rotation_size = 50MB
135 max_connections = 100
136 datestyle = 'iso, mdy'
137
138 lc_messages = 'en_US.UTF-8'
139 lc_monetary = 'en_US.UTF-8'
140 lc_numeric = 'en_US.UTF-8'
141 lc_time = 'en_US.UTF-8'
142 default_text_search_config = 'pg_catalog.english'
143
144 unix_socket_directory = '%s'
145 unix_socket_permissions = 0700
146 """ % (
147 ','.join(ipv4.union(ipv6)),
148 pgdata,
149 )))
150
151 with open(os.path.join(pgdata, 'pg_hba.conf'), 'wb') as cfg:
152 # see http://www.postgresql.org/docs/9.2/static/auth-pg-hba-conf.html
153
154 cfg_lines = [
155 '# TYPE DATABASE USER ADDRESS METHOD',
156 '',
157 '# "local" is for Unix domain socket connections only (check unix_socket_permissions!)',
158 'local all all ident',
159 'host all all 127.0.0.1/32 md5',
160 'host all all ::1/128 md5',
161 ]
162
163 for ip in ipv4:
164 cfg_lines.append('host all all %s/32 md5' % ip)
165
166 for ip in ipv6:
167 cfg_lines.append('host all all %s/128 md5' % ip)
168
169 cfg.write('\n'.join(cfg_lines))
170
171
172 def createDatabase(self):
173 self.runPostgresCommand(cmd='CREATE DATABASE "%s"' % self.options['dbname'])
174
175
176 def updateSuperuser(self):
177 """\
178 Set a password for the cluster administrator.
179 The application will also use it for its connections.
180 """
181
182 # http://postgresql.1045698.n5.nabble.com/Algorithm-for-generating-md5-encrypted-password-not-found-in-documentation-td4919082.html
183
184 user = self.options['superuser']
185 password = self.options['password']
186
187 # encrypt the password to avoid storing in the logs
188 enc_password = 'md5' + md5.md5(password+user).hexdigest()
189
190 self.runPostgresCommand(cmd="""ALTER USER "%s" ENCRYPTED PASSWORD '%s'""" % (user, enc_password))
191
192
193 def runPostgresCommand(self, cmd):
194 """\
195 Executes a command in single-user mode, with no daemon running.
196
197 Multiple commands can be executed by providing newlines,
198 preceeded by backslash, between them.
199 See http://www.postgresql.org/docs/9.1/static/app-postgres.html
200 """
201
202 pgdata = self.options['pgdata-directory']
203 postgres_binary = os.path.join(self.options['bin'], 'postgres')
204
205 try:
206 p = subprocess.Popen([postgres_binary,
207 '--single',
208 '-D', pgdata,
209 'postgres',
210 ], stdin=subprocess.PIPE)
211
212 p.communicate(cmd+'\n')
213 except subprocess.CalledProcessError:
214 raise UserError('Could not create database %s' % pgdata)
215
216
217 def createRunScript(self):
218 """\
219 Creates a script that runs postgres in the foreground.
220 'exec' is used to allow easy control by supervisor.
221 """
222 content = textwrap.dedent("""\
223 #!/bin/sh
224 exec %(bin)s/postgres \\
225 -D %(pgdata-directory)s
226 """ % self.options)
227 name = os.path.join(self.options['services'], 'postgres-start')
228 self.createExecutable(name, content=content)
229
230
231
232 class ExportRecipe(GenericBaseRecipe):
233 """\
234 This recipe creates an exporter script for using with the resilient stack.
235
236 Required options:
237 backup-directory
238 folder that will contain the dump file.
239 bin
240 path to the 'pg_dump' binary.
241 dbname
242 name of the database to dump.
243 pgdata-directory
244 path to postgres configuration and data.
245 wrapper
246 full path of the exporter script to create.
247 """
248
249 def install(self):
250 wrapper = self.options['wrapper']
251 self.createBackupScript(wrapper)
252 return [wrapper]
253
254
255 def createBackupScript(self, wrapper):
256 """\
257 Create a script to backup the database in 'custom' format.
258 """
259 content = textwrap.dedent("""\
260 #!/bin/sh
261 umask 077
262 %(bin)s/pg_dump \\
263 --host=%(pgdata-directory)s \\
264 --format=custom \\
265 --file=%(backup-directory)s/database.dump \\
266 %(dbname)s
267 """ % self.options)
268 self.createExecutable(wrapper, content=content)
269
270
271
272 class ImportRecipe(GenericBaseRecipe):
273 """\
274 This recipe creates an importer script for using with the resilient stack.
275
276 Required options:
277 backup-directory
278 folder that contains the dump file.
279 bin
280 path to the 'pg_restore' binary.
281 dbname
282 name of the database to restore.
283 pgdata-directory
284 path to postgres configuration and data.
285 wrapper
286 full path of the importer script to create.
287 """
288
289 def install(self):
290 wrapper = self.options['wrapper']
291 self.createRestoreScript(wrapper)
292 return [wrapper]
293
294
295 def createRestoreScript(self, wrapper):
296 """\
297 Create a script to restore the database from 'custom' format.
298 """
299 content = textwrap.dedent("""\
300 #!/bin/sh
301 %(bin)s/pg_restore \\
302 --host=%(pgdata-directory)s \\
303 --dbname=%(dbname)s \\
304 --clean \\
305 --no-owner \\
306 --no-acl \\
307 %(backup-directory)s/database.dump
308 """ % self.options)
309 self.createExecutable(wrapper, content=content)
310
311