Blame view

slapos/recipe/postgres/__init__.py 8.61 KB
Marco Mariani committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
##############################################################################
#
# Copyright (c) 2010 Vifib SARL and Contributors. All Rights Reserved.
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsibility of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
# guarantees and support are strongly adviced to contract a Free Software
# Service Company
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
#
##############################################################################

Bryton Lacquement committed
28
import hashlib
Marco Mariani committed
29 30 31
import os
import subprocess
import textwrap
Kirill Smelkov committed
32
import shutil
Marco Mariani committed
33 34 35 36
from zc.buildout import UserError

from slapos.recipe.librecipe import GenericBaseRecipe

Marco Mariani committed
37

Marco Mariani committed
38

Marco Mariani committed
39
class Recipe(GenericBaseRecipe):
Marco Mariani committed
40 41 42 43
    """\
    This recipe creates:

        - a Postgres cluster
Marco Mariani committed
44
        - configuration to allow connections from IPv4, IPv6 or unix socket.
Marco Mariani committed
45 46
        - a superuser with provided name and generated password
        - a database with provided name
Marco Mariani committed
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
        - a start script in the services directory

    Required options:
        bin
            path to the 'initdb' and 'postgres' binaries.
        dbname
            name of the database to be used by the application.
        ipv4
            set of ipv4 to listen on.
        ipv6
            set of ipv6 to listen on.
        pgdata-directory
            path to postgres configuration and data.
        services
            must be ${buildout:directory}/etc/service.
        superuser
            name of the superuser to create.

    Exposed options:
        password
            generated password for the superuser.
        url
            generated DBAPI connection string.
            it can be used as-is (ie. in sqlalchemy) or by the _urlparse.py recipe.
Marco Mariani committed
71
    """
Marco Mariani committed
72 73

    def _options(self, options):
Marco Mariani committed
74
        options['url'] = 'postgresql://%(superuser)s:%(password)s@[%(ipv6-random)s]:%(port)s/%(dbname)s' % options
Marco Mariani committed
75 76 77 78 79


    def install(self):
        pgdata = self.options['pgdata-directory']

Marco Mariani committed
80 81
        # if the pgdata already exists, skip all steps, we don't need to do anything.

Marco Mariani committed
82
        if not os.path.exists(pgdata):
Kirill Smelkov committed
83 84 85 86 87 88 89 90 91 92 93 94 95
            try:
                self.createCluster()
                self.createConfig()
                self.createDatabase()
                self.updateSuperuser()
                self.createRunScript()
            except:
                # do not leave half-installed postgresql - else next time we
                # run we won't update it.
                shutil.rmtree(pgdata)
                raise


Marco Mariani committed
96

Marco Mariani committed
97 98 99 100 101 102
        # install() methods usually return the pathnames of managed files.
        # If they are missing, they will be rebuilt.
        # In this case, we already check for the existence of pgdata,
        # so we don't need to return anything here.

        return []
Marco Mariani committed
103 104


Marco Mariani committed
105 106 107 108 109
    def check_exists(self, path):
        if not os.path.isfile(path):
            raise IOError('File not found: %s' % path)


Marco Mariani committed
110
    def createCluster(self):
Marco Mariani committed
111 112 113 114
        """\
        A Postgres cluster is "a collection of databases that is managed
        by a single instance of a running database server".

Marco Mariani committed
115
        Here we create an empty cluster.
Marco Mariani committed
116
        """
Marco Mariani committed
117
        initdb_binary = os.path.join(self.options['bin'], 'initdb')
Marco Mariani committed
118
        self.check_exists(initdb_binary)
Marco Mariani committed
119 120 121

        pgdata = self.options['pgdata-directory']

Marco Mariani committed
122 123 124 125 126
        try:
            subprocess.check_call([initdb_binary,
                                   '-D', pgdata,
                                   '-A', 'ident',
                                   '-E', 'UTF8',
Marco Mariani committed
127
                                   '-U', self.options['superuser'],
Marco Mariani committed
128 129 130
                                   ])
        except subprocess.CalledProcessError:
            raise UserError('Could not create cluster directory in %s' % pgdata)
Marco Mariani committed
131 132 133


    def createConfig(self):
Marco Mariani committed
134
        pgdata = self.options['pgdata-directory']
Marco Mariani committed
135 136
        ipv4 = self.options['ipv4']
        ipv6 = self.options['ipv6']
Marco Mariani committed
137

Marco Mariani committed
138 139
        with open(os.path.join(pgdata, 'postgresql.conf'), 'wb') as cfg:
            cfg.write(textwrap.dedent("""\
Marco Mariani committed
140
                    listen_addresses = '%s'
Marco Mariani committed
141 142 143 144 145 146 147 148 149 150
                    logging_collector = on
                    log_rotation_size = 50MB
                    max_connections = 100
                    datestyle = 'iso, mdy'

                    lc_messages = 'en_US.UTF-8'
                    lc_monetary = 'en_US.UTF-8'
                    lc_numeric = 'en_US.UTF-8'
                    lc_time = 'en_US.UTF-8'
                    default_text_search_config = 'pg_catalog.english'
Marco Mariani committed
151 152 153 154

                    unix_socket_directory = '%s'
                    unix_socket_permissions = 0700
                    """ % (
Marco Mariani committed
155
                        ','.join(ipv4.union(ipv6)),
Marco Mariani committed
156 157
                        pgdata,
                        )))
Marco Mariani committed
158 159

        with open(os.path.join(pgdata, 'pg_hba.conf'), 'wb') as cfg:
Marco Mariani committed
160
            # see http://www.postgresql.org/docs/9.2/static/auth-pg-hba-conf.html
Marco Mariani committed
161

Marco Mariani committed
162 163 164 165
            cfg_lines = [
                '# TYPE  DATABASE        USER            ADDRESS                 METHOD',
                '',
                '# "local" is for Unix domain socket connections only (check unix_socket_permissions!)',
Marco Mariani committed
166
                'local   all             all                                     trust',
Marco Mariani committed
167 168 169 170
                'host    all             all             127.0.0.1/32            md5',
                'host    all             all             ::1/128                 md5',
            ]

Marco Mariani committed
171
            ipv4_netmask_bits = self.options.get('ipv4-netmask-bits', '32')
Marco Mariani committed
172
            for ip in ipv4:
Marco Mariani committed
173
                cfg_lines.append('host    all             all             %s/%s                   md5' % (ip, ipv4_netmask_bits))
Marco Mariani committed
174

Marco Mariani committed
175
            ipv6_netmask_bits = self.options.get('ipv6-netmask-bits', '128')
Marco Mariani committed
176
            for ip in ipv6:
Marco Mariani committed
177
                cfg_lines.append('host    all             all             %s/%s                   md5' % (ip, ipv6_netmask_bits))
Marco Mariani committed
178 179

            cfg.write('\n'.join(cfg_lines))
Marco Mariani committed
180 181 182


    def createDatabase(self):
Marco Mariani committed
183 184 185
        self.runPostgresCommand(cmd='CREATE DATABASE "%s"' % self.options['dbname'])


Marco Mariani committed
186
    def updateSuperuser(self):
Marco Mariani committed
187
        """\
Marco Mariani committed
188 189
        Set a password for the cluster administrator.
        The application will also use it for its connections.
Marco Mariani committed
190
        """
Marco Mariani committed
191 192 193

        # http://postgresql.1045698.n5.nabble.com/Algorithm-for-generating-md5-encrypted-password-not-found-in-documentation-td4919082.html

Marco Mariani committed
194
        user = self.options['superuser']
Marco Mariani committed
195 196 197
        password = self.options['password']

        # encrypt the password to avoid storing in the logs
Bryton Lacquement committed
198
        enc_password = 'md5' + hashlib.md5(password+user).hexdigest()
Marco Mariani committed
199

Marco Mariani committed
200
        self.runPostgresCommand(cmd="""ALTER USER "%s" ENCRYPTED PASSWORD '%s'""" % (user, enc_password))
Marco Mariani committed
201 202 203


    def runPostgresCommand(self, cmd):
Marco Mariani committed
204
        """\
Marco Mariani committed
205 206 207 208 209 210 211
        Executes a command in single-user mode, with no daemon running.

        Multiple commands can be executed by providing newlines,
        preceeded by backslash, between them.
        See http://www.postgresql.org/docs/9.1/static/app-postgres.html
        """

Marco Mariani committed
212 213 214 215 216 217 218 219 220
        pgdata = self.options['pgdata-directory']
        postgres_binary = os.path.join(self.options['bin'], 'postgres')

        try:
            p = subprocess.Popen([postgres_binary,
                                  '--single',
                                  '-D', pgdata,
                                  'postgres',
                                  ], stdin=subprocess.PIPE)
Marco Mariani committed
221

Marco Mariani committed
222
            p.communicate(cmd+'\n')
Marco Mariani committed
223 224 225 226 227
        except subprocess.CalledProcessError:
            raise UserError('Could not create database %s' % pgdata)


    def createRunScript(self):
Marco Mariani committed
228
        """\
Marco Mariani committed
229 230 231
        Creates a script that runs postgres in the foreground.
        'exec' is used to allow easy control by supervisor.
        """
Marco Mariani committed
232 233
        content = textwrap.dedent("""\
                #!/bin/sh
Marco Mariani committed
234
                exec %(bin)s/postgres \\
Marco Mariani committed
235 236 237 238 239 240
                    -D %(pgdata-directory)s
                """ % self.options)
        name = os.path.join(self.options['services'], 'postgres-start')
        self.createExecutable(name, content=content)