manual port of changes on lapp-resilient over this new branch
[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 IPV6 only (or unix socket)
44 - a superuser with provided name and generated password
45 - a database with provided name
46 - a foreground start script in the services directory
47
48 then adds the connection URL to the options.
49 The URL can be used as-is (ie. in sqlalchemy) or by the _urlparse.py recipe.
50 """
51
52 def fetch_host(self, options):
53 """
54 Returns a string represtation of ipv6_host.
55 May receive a regular string, a set or a string serialized by buildout.
56 """
57 ipv6_host = options['ipv6_host']
58
59 if isinstance(ipv6_host, set):
60 return ipv6_host.pop()
61 else:
62 return ipv6_host
63
64
65 def _options(self, options):
66 options['password'] = self.generatePassword()
67 options['url'] = 'postgresql://%(user)s:%(password)s@[%(host)s]:%(port)s/%(dbname)s' % dict(options, host=self.fetch_host(options))
68
69
70 def install(self):
71 pgdata = self.options['pgdata-directory']
72
73 if not os.path.exists(pgdata):
74 self.createCluster()
75 self.createConfig()
76 self.createDatabase()
77 self.createSuperuser()
78 self.createRunScript()
79
80 return [
81 # XXX should we really return something here?
82 # os.path.join(pgdata, 'postgresql.conf')
83 ]
84
85
86 def check_exists(self, path):
87 if not os.path.isfile(path):
88 raise IOError('File not found: %s' % path)
89
90
91 def createCluster(self):
92 initdb_binary = os.path.join(self.options['bin'], 'initdb')
93 self.check_exists(initdb_binary)
94
95 pgdata = self.options['pgdata-directory']
96
97 try:
98 subprocess.check_call([initdb_binary,
99 '-D', pgdata,
100 '-A', 'ident',
101 '-E', 'UTF8',
102 ])
103 except subprocess.CalledProcessError:
104 raise UserError('Could not create cluster directory in %s' % pgdata)
105
106
107 def createConfig(self):
108 pgdata = self.options['pgdata-directory']
109
110 with open(os.path.join(pgdata, 'postgresql.conf'), 'wb') as cfg:
111 cfg.write(textwrap.dedent("""\
112 listen_addresses = '%s'
113 logging_collector = on
114 log_rotation_size = 50MB
115 max_connections = 100
116 datestyle = 'iso, mdy'
117
118 lc_messages = 'en_US.UTF-8'
119 lc_monetary = 'en_US.UTF-8'
120 lc_numeric = 'en_US.UTF-8'
121 lc_time = 'en_US.UTF-8'
122 default_text_search_config = 'pg_catalog.english'
123
124 unix_socket_directory = '%s'
125 unix_socket_permissions = 0700
126 """ % (
127 self.fetch_host(self.options),
128 pgdata,
129 )))
130
131
132 with open(os.path.join(pgdata, 'pg_hba.conf'), 'wb') as cfg:
133 # see http://www.postgresql.org/docs/9.1/static/auth-pg-hba-conf.html
134
135 cfg.write(textwrap.dedent("""\
136 # TYPE DATABASE USER ADDRESS METHOD
137
138 # "local" is for Unix domain socket connections only (check unix_socket_permissions!)
139 local all all ident
140 host all all 127.0.0.1/32 md5
141 host all all ::1/128 md5
142 host all all %s/128 md5
143 """ % self.fetch_host(self.options)))
144
145
146 def createDatabase(self):
147 self.runPostgresCommand(cmd='CREATE DATABASE "%s"' % self.options['dbname'])
148
149
150 def createSuperuser(self):
151 """
152 Creates a Postgres superuser - other than "slapuser#" for use by the application.
153 """
154
155 # http://postgresql.1045698.n5.nabble.com/Algorithm-for-generating-md5-encrypted-password-not-found-in-documentation-td4919082.html
156
157 user = self.options['user']
158 password = self.options['password']
159
160 # encrypt the password to avoid storing in the logs
161 enc_password = 'md5' + md5.md5(password+user).hexdigest()
162
163 self.runPostgresCommand(cmd="""CREATE USER "%s" ENCRYPTED PASSWORD '%s' SUPERUSER""" % (user, enc_password))
164
165
166 def runPostgresCommand(self, cmd):
167 """
168 Executes a command in single-user mode, with no daemon running.
169
170 Multiple commands can be executed by providing newlines,
171 preceeded by backslash, between them.
172 See http://www.postgresql.org/docs/9.1/static/app-postgres.html
173 """
174
175 pgdata = self.options['pgdata-directory']
176 postgres_binary = os.path.join(self.options['bin'], 'postgres')
177
178 try:
179 p = subprocess.Popen([postgres_binary,
180 '--single',
181 '-D', pgdata,
182 'postgres',
183 ], stdin=subprocess.PIPE)
184
185 p.communicate(cmd+'\n')
186 except subprocess.CalledProcessError:
187 raise UserError('Could not create database %s' % pgdata)
188
189
190 def createRunScript(self):
191 """
192 Creates a script that runs postgres in the foreground.
193 'exec' is used to allow easy control by supervisor.
194 """
195 content = textwrap.dedent("""\
196 #!/bin/sh
197 exec %(bin)s/postgres \\
198 -D %(pgdata-directory)s
199 """ % self.options)
200 name = os.path.join(self.options['services'], 'postgres-start')
201 self.createExecutable(name, content=content)
202
203
204
205 class ExportRecipe(GenericBaseRecipe):
206
207 def install(self):
208 pgdata = self.options['pgdata-directory']
209
210 ret = []
211
212 if not os.path.exists(pgdata):
213 wrapper = self.options['wrapper']
214 self.createBackupScript(wrapper)
215 ret.append(wrapper)
216
217 return ret
218
219
220 def createBackupScript(self, wrapper):
221 """
222 Create a script to backup the database in plain SQL format.
223 """
224 content = textwrap.dedent("""\
225 #!/bin/sh
226 umask 077
227 %(bin)s/pg_dump -h %(pgdata-directory)s -f %(backup-directory)s/backup.sql %(dbname)s
228 """ % self.options)
229 self.createExecutable(wrapper, content=content)
230
231
232
233 class ImportRecipe(GenericBaseRecipe):
234
235 def install(self):
236 pgdata = self.options['pgdata-directory']
237
238 ret = []
239 if not os.path.exists(pgdata):
240 wrapper = self.options['wrapper']
241 self.createRestoreScript(wrapper)
242 ret.append(wrapper)
243
244 return ret
245
246
247 def createRestoreScript(self, wrapper):
248 """
249 Create a script to backup the database in plain SQL format.
250 """
251 content = textwrap.dedent("""\
252 #!/bin/sh
253 %(bin)s/pg_restore -h %(pgdata-directory)s -d %(dbname)s %(backup-directory)s/backup.sql
254 """ % self.options)
255 self.createExecutable(wrapper, content=content)
256
257
258
259