├── .gitignore ├── LICENSE ├── README.md ├── defaults └── main.yml ├── handlers └── main.yml ├── library ├── postgresql_command.py ├── postgresql_query.py ├── postgresql_row.py ├── postgresql_table.py └── utils │ └── __init__.py ├── meta └── main.yml ├── module_utils ├── __init__.py ├── connection.py └── table.py ├── tasks └── main.yml ├── tests ├── inventory └── test.yml └── vars └── main.yml /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff: 7 | .idea/workspace.xml 8 | .idea/tasks.xml 9 | 10 | # Sensitive or high-churn files: 11 | .idea/dataSources/ 12 | .idea/dataSources.ids 13 | .idea/dataSources.xml 14 | .idea/dataSources.local.xml 15 | .idea/sqlDataSources.xml 16 | .idea/dynamic.xml 17 | .idea/uiDesigner.xml 18 | 19 | # Gradle: 20 | .idea/gradle.xml 21 | .idea/libraries 22 | 23 | # Mongo Explorer plugin: 24 | .idea/mongoSettings.xml 25 | 26 | ## File-based project format: 27 | *.iws 28 | 29 | ## Plugin-specific files: 30 | 31 | # IntelliJ 32 | /out/ 33 | 34 | # mpeltonen/sbt-idea plugin 35 | .idea_modules/ 36 | 37 | # JIRA plugin 38 | atlassian-ide-plugin.xml 39 | 40 | # Crashlytics plugin (for Android Studio and IntelliJ) 41 | com_crashlytics_export_strings.xml 42 | crashlytics.properties 43 | crashlytics-build.properties 44 | fabric.properties 45 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, Denis Gasparin 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pgsql 2 | ========= 3 | 4 | Provides four new ansible modules for Postgresql: 5 | - postgresql_table: ensure that a table is present (or absent) in database 6 | - postgresql_row: ensure that a row is present (or absent) in a table 7 | - postgresql_query: execute an arbitrary query in database and return results 8 | - postgresql_command: execute an arbitrary query in database 9 | 10 | For additional docs look project's wiki: https://github.com/rtshome/ansible_pgsql/wiki 11 | 12 | 13 | Requirements 14 | ------------ 15 | 16 | It requires psycopg2 installed as per Ansible's PostgreSQL modules: http://docs.ansible.com/ansible/latest/list_of_database_modules.html#postgresql 17 | 18 | Role Variables 19 | -------------- 20 | 21 | No variables are defined by the module 22 | 23 | Dependencies 24 | ------------ 25 | 26 | 27 | 28 | Example Playbook 29 | ---------------- 30 | 31 | Sample playbook that: 32 | - creates the table `config` in `acme` database 33 | - ensures that a row is present in `config` table 34 | - performs a SELECT query on `config` and stores results in `query` var 35 | - execute a command removing all records in `logs` table 36 | 37 | ```yaml 38 | - hosts: servers 39 | tasks: 40 | - postgresql_table: 41 | database: acme 42 | name: config 43 | state: present 44 | columns: 45 | - { 46 | name: key, 47 | type: text, 48 | null: False 49 | } 50 | - { 51 | name: value, 52 | type: text, 53 | null: False 54 | } 55 | primary_key: 56 | - key 57 | 58 | - postgresql_row: 59 | database: acme 60 | table: config 61 | row: 62 | key: env 63 | value: production 64 | 65 | - postgresql_query: 66 | database: acme 67 | query: SELECT * FROM config WHERE env = %(env)s 68 | parameters: 69 | env: production 70 | register: query 71 | 72 | - postgresql_command: 73 | database: acme 74 | command: "TRUNCATE logs" 75 | roles: 76 | - rtshome.pgsql 77 | ``` 78 | 79 | License 80 | ------- 81 | 82 | BSD 83 | 84 | Author Information 85 | ------------------ 86 | 87 | Denis Gasparin 88 | http://www.gasparin.net 89 | -------------------------------------------------------------------------------- /defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # defaults file for pgsql -------------------------------------------------------------------------------- /handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # handlers file for pgsql -------------------------------------------------------------------------------- /library/postgresql_command.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | try: 3 | import psycopg2 4 | import psycopg2.extras 5 | except ImportError: 6 | postgresqldb_found = False 7 | else: 8 | postgresqldb_found = True 9 | 10 | from ansible.module_utils.basic import AnsibleModule 11 | from ansible.module_utils._text import to_native 12 | from ansible.module_utils.pycompat24 import get_exception 13 | 14 | import ast 15 | import traceback 16 | 17 | from ansible.module_utils.connection import * 18 | from ansible.module_utils.table import * 19 | 20 | # Needed to have pycharm autocompletition working 21 | try: 22 | from module_utils.connection import * 23 | except: 24 | pass 25 | 26 | DOCUMENTATION = ''' 27 | --- 28 | module: postgresql_command 29 | 30 | short_description: execute a command in a PostGreSQL database and return the affected rows number 31 | 32 | version_added: "2.3" 33 | 34 | description: 35 | - "execute a command in a PostGreSQL database and return the affected rows number" 36 | 37 | options: 38 | database: 39 | description: 40 | - Name of the database to connect to. 41 | default: postgres 42 | login_host: 43 | description: 44 | - Host running the database. 45 | default: localhost 46 | login_password: 47 | description: 48 | - The password used to authenticate with. 49 | login_unix_socket: 50 | description: 51 | - Path to a Unix domain socket for local connections. 52 | login_user: 53 | description: 54 | - The username used to authenticate with. 55 | port: 56 | description: 57 | - Database port to connect to. 58 | default: 5432 59 | command: 60 | description: 61 | - The SQL command to execute 62 | required: true 63 | parameters: 64 | description: 65 | - | 66 | Parameters of the SQL command as list (if positional parameters are used in SQL) 67 | or as dictionary (if named parameters are used). 68 | Psycopg2 syntax is required for parameters. 69 | See: http://initd.org/psycopg/docs/usage.html#passing-parameters-to-sql-queries 70 | 71 | extends_documentation_fragment: 72 | - Postgresql 73 | 74 | notes: 75 | - This module uses I(psycopg2), a Python PostgreSQL database adapter. You must ensure that psycopg2 is installed on 76 | the host before using this module. If the remote host is the PostgreSQL server (which is the default case), 77 | then PostgreSQL must also be installed on the remote host. 78 | For Ubuntu-based systems, install the C(postgresql), C(libpq-dev), and C(python-psycopg2) packages 79 | on the remote host before using this module. 80 | 81 | requirements: [ psycopg2 ] 82 | 83 | author: 84 | - Denis Gasparin (@rtshome) 85 | ''' 86 | 87 | EXAMPLES = ''' 88 | --- 89 | # Set the field status to FALSE for all rows of "my_table" 90 | - postgresql_command: 91 | database: my_app 92 | command: "UPDATE my_table SET status = FALSE" 93 | 94 | # Set the field status to FALSE for rows with id less than 10 95 | - postgresql_command: 96 | database: my_app 97 | command: "UPDATE my_table SET status = FALSE AND id < %(id)s" 98 | id: 10 99 | register: command_results 100 | ''' 101 | 102 | RETURN = ''' 103 | executed_command: 104 | description: the body of the SQL command sent to the backend (including bound arguments) as bytes string 105 | rowCount: 106 | description: number of rows affected by the command 107 | ''' 108 | 109 | 110 | def run_module(): 111 | module_args = dict( 112 | login_user=dict(default="postgres"), 113 | login_password=dict(default="", no_log=True), 114 | login_host=dict(default=""), 115 | login_unix_socket=dict(default=""), 116 | database=dict(default="postgres"), 117 | port=dict(default="5432"), 118 | command=dict(required=True), 119 | parameters=dict(default=[]) 120 | ) 121 | 122 | module = AnsibleModule( 123 | argument_spec=module_args, 124 | supports_check_mode=False 125 | ) 126 | 127 | database = module.params["database"] 128 | parameters = ast.literal_eval(module.params["parameters"]) 129 | 130 | if not postgresqldb_found: 131 | module.fail_json(msg="the python psycopg2 module is required") 132 | 133 | cursor = None 134 | try: 135 | cursor = connect(database, prepare_connection_params(module.params)) 136 | cursor.connection.autocommit = False 137 | cursor.execute(module.params["command"], parameters) 138 | 139 | cursor.connection.commit() 140 | 141 | module.exit_json( 142 | changed=True, 143 | executed_command=cursor.query, 144 | rowCount=cursor.rowcount 145 | ) 146 | 147 | except psycopg2.ProgrammingError: 148 | e = get_exception() 149 | module.fail_json(msg="query error: %s" % to_native(e)) 150 | except psycopg2.DatabaseError: 151 | e = get_exception() 152 | module.fail_json(msg="database error: %s" % to_native(e), exception=traceback.format_exc()) 153 | except TypeError: 154 | e = get_exception() 155 | module.fail_json(msg="parameters error: %s" % to_native(e)) 156 | finally: 157 | if cursor: 158 | cursor.connection.rollback() 159 | 160 | if __name__ == '__main__': 161 | run_module() 162 | -------------------------------------------------------------------------------- /library/postgresql_query.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | try: 3 | import psycopg2 4 | import psycopg2.extras 5 | import json 6 | except ImportError: 7 | postgresqldb_found = False 8 | else: 9 | postgresqldb_found = True 10 | 11 | from ansible.module_utils.basic import AnsibleModule 12 | from ansible.module_utils._text import to_native 13 | from ansible.module_utils.pycompat24 import get_exception 14 | 15 | import ast 16 | import traceback 17 | 18 | from ansible.module_utils.connection import * 19 | from ansible.module_utils.table import * 20 | 21 | # Needed to have pycharm autocompletition working 22 | try: 23 | from module_utils.connection import * 24 | except: 25 | pass 26 | 27 | DOCUMENTATION = ''' 28 | --- 29 | module: postgresql_query 30 | 31 | short_description: execute a query in a PostGreSQL database and return the results 32 | 33 | version_added: "2.4" 34 | 35 | description: 36 | - "execute a query in a PostGreSQL database and return the results" 37 | 38 | options: 39 | database: 40 | description: 41 | - Name of the database to connect to. 42 | default: postgres 43 | login_host: 44 | description: 45 | - Host running the database. 46 | default: localhost 47 | login_password: 48 | description: 49 | - The password used to authenticate with. 50 | login_unix_socket: 51 | description: 52 | - Path to a Unix domain socket for local connections. 53 | login_user: 54 | description: 55 | - The username used to authenticate with. 56 | port: 57 | description: 58 | - Database port to connect to. 59 | default: 5432 60 | query: 61 | description: 62 | - Query to execute 63 | required: true 64 | parameters: 65 | description: 66 | - | 67 | Parameters of the query as list (if positional parameters are used in query) 68 | or as dictionary (if named parameters are used). 69 | Psycopg2 syntax is required for parameters. 70 | See: http://initd.org/psycopg/docs/usage.html#passing-parameters-to-sql-queries 71 | 72 | extends_documentation_fragment: 73 | - Postgresql 74 | 75 | notes: 76 | - This module uses I(psycopg2), a Python PostgreSQL database adapter. You must ensure that psycopg2 is installed on 77 | the host before using this module. If the remote host is the PostgreSQL server (which is the default case), 78 | then PostgreSQL must also be installed on the remote host. 79 | For Ubuntu-based systems, install the C(postgresql), C(libpq-dev), and C(python-psycopg2) packages 80 | on the remote host before using this module. 81 | 82 | requirements: [ psycopg2 ] 83 | 84 | author: 85 | - Denis Gasparin (@rtshome) 86 | ''' 87 | 88 | EXAMPLES = ''' 89 | --- 90 | # Fetch all tables in my_app database 91 | - postgresql_query: 92 | database: my_app 93 | query: "SELECT * FROM pg_tables" 94 | 95 | # Fetch table info for table "pg_statistic" 96 | - postgresql_query: 97 | database: my_app 98 | query: "SELECT * FROM pg_tables WHERE tablename = %(table_name)s" 99 | table_name: pg_statistic 100 | register: query_results 101 | ''' 102 | 103 | RETURN = ''' 104 | executed_query: 105 | description: the body of the last query sent to the backend (including bound arguments) as bytes string 106 | rows: 107 | description: list of rows. Each row is a dict indexed using the column name 108 | rowCount: 109 | description: number of rows returned by the query 110 | ''' 111 | 112 | 113 | def run_module(): 114 | module_args = dict( 115 | login_user=dict(default="postgres"), 116 | login_password=dict(default="", no_log=True), 117 | login_host=dict(default=""), 118 | login_unix_socket=dict(default=""), 119 | database=dict(default="postgres"), 120 | port=dict(default="5432"), 121 | query=dict(required=True), 122 | parameters=dict(default=[]) 123 | ) 124 | 125 | module = AnsibleModule( 126 | argument_spec=module_args, 127 | supports_check_mode=False 128 | ) 129 | 130 | database = module.params["database"] 131 | parameters = ast.literal_eval(module.params["parameters"]) 132 | 133 | if not postgresqldb_found: 134 | module.fail_json(msg="the python psycopg2 module is required") 135 | 136 | cursor = None 137 | try: 138 | cursor = connect(database, prepare_connection_params(module.params)) 139 | cursor.connection.autocommit = False 140 | 141 | if not parameters: 142 | parameters = [] 143 | 144 | cursor.execute(module.params["query"], parameters) 145 | 146 | module.exit_json( 147 | changed=True, 148 | executed_query=cursor.query, 149 | # Json encoding/decoding is needed because RealDictCursor is not handled correctly 150 | # by module.exit_json in ansible 2.4 151 | rows=json.loads(json.dumps(cursor.fetchall())), 152 | row_count=cursor.rowcount 153 | ) 154 | 155 | except psycopg2.ProgrammingError: 156 | e = get_exception() 157 | module.fail_json( 158 | msg="database error: the query did not produce any resultset, %s" % to_native(e), 159 | exception=traceback.format_exc() 160 | ) 161 | except psycopg2.DatabaseError: 162 | e = get_exception() 163 | module.fail_json(msg="database error: %s" % to_native(e), exception=traceback.format_exc()) 164 | except TypeError: 165 | e = get_exception() 166 | module.fail_json(msg="parameters error: %s" % to_native(e), exception=traceback.format_exc()) 167 | finally: 168 | if cursor: 169 | cursor.connection.rollback() 170 | 171 | if __name__ == '__main__': 172 | run_module() 173 | -------------------------------------------------------------------------------- /library/postgresql_row.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | try: 3 | import psycopg2 4 | import psycopg2.extras 5 | from psycopg2 import sql 6 | except ImportError: 7 | postgresqldb_found = False 8 | else: 9 | postgresqldb_found = True 10 | 11 | from ansible.module_utils.basic import AnsibleModule 12 | from ansible.module_utils._text import to_native 13 | from ansible.module_utils.pycompat24 import get_exception 14 | 15 | import ast 16 | import traceback 17 | 18 | from ansible.module_utils.connection import * 19 | from ansible.module_utils.table import * 20 | 21 | # Needed to have pycharm autocompletition working 22 | # noinspection PyBroadException 23 | try: 24 | from module_utils.connection import * 25 | except: 26 | pass 27 | 28 | 29 | DOCUMENTATION = ''' 30 | --- 31 | module: postgresql_row 32 | 33 | short_description: add or remove a row from PostgreSQL table 34 | 35 | version_added: "2.3" 36 | 37 | description: 38 | - "add or remove a row from PostgreSQL table" 39 | 40 | options: 41 | database: 42 | description: 43 | - Name of the database to connect to. 44 | default: postgres 45 | login_host: 46 | description: 47 | - Host running the database. 48 | default: localhost 49 | login_password: 50 | description: 51 | - The password used to authenticate with. 52 | login_unix_socket: 53 | description: 54 | - Path to a Unix domain socket for local connections. 55 | login_user: 56 | description: 57 | - The username used to authenticate with. 58 | port: 59 | description: 60 | - Database port to connect to. 61 | default: 5432 62 | schema: 63 | description: 64 | - Schema where the table is defined 65 | default: public 66 | table: 67 | description: 68 | - The table to check for row presence/absence 69 | required: true 70 | row: 71 | description: 72 | - Dictionary with the fields of the row 73 | required: true 74 | state: 75 | description: 76 | - The row state 77 | choices: 78 | - present 79 | - absent 80 | 81 | extends_documentation_fragment: 82 | - Postgresql 83 | 84 | notes: 85 | - This module uses I(psycopg2), a Python PostgreSQL database adapter. You must ensure that psycopg2 is installed on 86 | the host before using this module. If the remote host is the PostgreSQL server (which is the default case), 87 | then PostgreSQL must also be installed on the remote host. 88 | For Ubuntu-based systems, install the C(postgresql), C(libpq-dev), and C(python-psycopg2) packages 89 | on the remote host before using this module. 90 | 91 | requirements: [ psycopg2 ] 92 | 93 | author: 94 | - Denis Gasparin (@rtshome) 95 | ''' 96 | 97 | EXAMPLES = ''' 98 | --- 99 | # Ensure row with fields key="environment" and value="production" is present in db 100 | - postgresql_row: 101 | database: my_app_config 102 | table: app_config 103 | row: 104 | key: environment 105 | value: production 106 | state: 107 | present 108 | ''' 109 | 110 | RETURN = ''' 111 | executed_query: 112 | description: the body of the last query sent to the backend (including bound arguments) as bytes string 113 | executed_command: 114 | description: the body of the command executed to insert the missing rows including bound arguments 115 | ''' 116 | 117 | 118 | def run_module(): 119 | module_args = dict( 120 | login_user=dict(default="postgres"), 121 | login_password=dict(default="", no_log=True), 122 | login_host=dict(default=""), 123 | login_unix_socket=dict(default=""), 124 | database=dict(default="postgres"), 125 | port=dict(default="5432"), 126 | schema=dict(default="public"), 127 | table=dict(required=True), 128 | row=dict(required=True), 129 | state=dict(default="present"), 130 | ) 131 | 132 | module = AnsibleModule( 133 | argument_spec=module_args, 134 | supports_check_mode=True 135 | ) 136 | 137 | database = module.params["database"] 138 | schema = module.params["schema"] 139 | table = module.params["table"] 140 | row_columns = ast.literal_eval(module.params["row"]) 141 | state = module.params["state"] 142 | 143 | if not postgresqldb_found: 144 | module.fail_json(msg="the python psycopg2 module is required") 145 | 146 | cursor = None 147 | try: 148 | cursor = connect(database, prepare_connection_params(module.params)) 149 | cursor.connection.autocommit = False 150 | sql_identifiers = { 151 | 'schema': sql.Identifier(schema), 152 | 'table': sql.Identifier(table) 153 | } 154 | sql_where = [] 155 | sql_insert_columns = [] 156 | sql_parameters = [] 157 | 158 | col_id = 0 159 | for c, v in row_columns.iteritems(): 160 | sql_identifiers['col_%d' % col_id] = sql.Identifier(c) 161 | sql_where.append('{%s} = %%s' % ('col_%d' % col_id)) 162 | sql_insert_columns.append('{%s}' % ('col_%d' % col_id)) 163 | sql_parameters.append(v) 164 | col_id += 1 165 | 166 | cursor.execute("LOCK {schema}.{table}".format(schema=schema, table=table)) 167 | 168 | cursor.execute( 169 | sql.SQL( 170 | "SELECT COUNT(*) FROM {schema}.{table} WHERE " + " AND ".join(sql_where) 171 | ).format(**sql_identifiers), 172 | sql_parameters 173 | ) 174 | executed_query = cursor.query 175 | row_count = cursor.fetchone()['count'] 176 | 177 | if row_count > 1: 178 | raise psycopg2.ProgrammingError('More than 1 one returned by selection query %s' % executed_query) 179 | 180 | changed = False 181 | if state == 'present' and row_count != 1: 182 | changed = True 183 | if state == 'absent' and row_count == 1: 184 | changed = True 185 | 186 | if module.check_mode or not changed: 187 | cursor.connection.rollback() 188 | module.exit_json( 189 | changed=changed, 190 | executed_query=executed_query 191 | ) 192 | 193 | if state == 'present': 194 | cursor.execute( 195 | sql.SQL( 196 | 'INSERT INTO {schema}.{table} (' + ', '.join(sql_insert_columns) + ') ' + 197 | 'VALUES (' + ', '.join(['%s'] * len(sql_parameters)) + ')' 198 | ).format(**sql_identifiers), 199 | sql_parameters 200 | ) 201 | executed_cmd = cursor.query 202 | else: 203 | cursor.execute( 204 | sql.SQL( 205 | 'DELETE FROM {schema}.{table} WHERE ' + ' AND '.join(sql_where) 206 | ).format(**sql_identifiers), 207 | sql_parameters 208 | ) 209 | executed_cmd = cursor.query 210 | 211 | cursor.connection.commit() 212 | 213 | module.exit_json( 214 | changed=changed, 215 | executed_query=executed_query, 216 | executed_command=executed_cmd, 217 | ) 218 | 219 | except psycopg2.ProgrammingError: 220 | e = get_exception() 221 | module.fail_json(msg="database error: the query did not produce any resultset, %s" % to_native(e)) 222 | except psycopg2.DatabaseError: 223 | e = get_exception() 224 | module.fail_json(msg="database error: %s" % to_native(e), exception=traceback.format_exc()) 225 | except TypeError: 226 | e = get_exception() 227 | module.fail_json(msg="parameters error: %s" % to_native(e)) 228 | finally: 229 | if cursor: 230 | cursor.connection.rollback() 231 | 232 | if __name__ == '__main__': 233 | run_module() 234 | -------------------------------------------------------------------------------- /library/postgresql_table.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | try: 3 | import psycopg2 4 | import psycopg2.extras 5 | from psycopg2 import sql 6 | except ImportError: 7 | postgresqldb_found = False 8 | else: 9 | postgresqldb_found = True 10 | 11 | from ansible.module_utils.basic import AnsibleModule 12 | from ansible.module_utils._text import to_native 13 | from ansible.module_utils.pycompat24 import get_exception 14 | 15 | import ast 16 | import traceback 17 | 18 | from ansible.module_utils.connection import * 19 | from ansible.module_utils.table import * 20 | 21 | # Needed to have pycharm autocompletition working 22 | try: 23 | from module_utils.connection import * 24 | from module_utils.table import * 25 | except: 26 | pass 27 | 28 | DOCUMENTATION = ''' 29 | --- 30 | module: postgresql_table 31 | 32 | short_description: Add or remove a table in a PostGreSQL database 33 | 34 | version_added: "2.3" 35 | 36 | description: 37 | - "Add or remove a table in a PostGreSQL database" 38 | 39 | options: 40 | database: 41 | description: 42 | - Name of the database to connect to. 43 | default: postgres 44 | login_host: 45 | description: 46 | - Host running the database. 47 | default: localhost 48 | login_password: 49 | description: 50 | - The password used to authenticate with. 51 | login_unix_socket: 52 | description: 53 | - Path to a Unix domain socket for local connections. 54 | login_user: 55 | description: 56 | - The username used to authenticate with. 57 | name: 58 | description: 59 | - Name of the table 60 | required: true 61 | schema: 62 | description: 63 | - Schema of the table 64 | owner: 65 | description: 66 | - Owner of the table 67 | port: 68 | description: 69 | - Database port to connect to. 70 | default: 5432 71 | state: 72 | description: 73 | - The table state 74 | default: present 75 | choices: 76 | - present 77 | - absent 78 | columns: 79 | description: 80 | - List of objects with name, type and null keys 81 | required: true 82 | primary_key: 83 | description: 84 | - List with column names composing the primary key 85 | 86 | extends_documentation_fragment: 87 | - Postgresql 88 | 89 | notes: 90 | - This module uses I(psycopg2), a Python PostgreSQL database adapter. You must ensure that psycopg2 is installed on 91 | the host before using this module. If the remote host is the PostgreSQL server (which is the default case), 92 | then PostgreSQL must also be installed on the remote host. 93 | For Ubuntu-based systems, install the C(postgresql), C(libpq-dev), and C(python-psycopg2) packages 94 | on the remote host before using this module. 95 | 96 | requirements: [ psycopg2 ] 97 | 98 | author: 99 | - Denis Gasparin (@rtshome) 100 | ''' 101 | 102 | EXAMPLES = ''' 103 | --- 104 | # Create a table in my_app database named "config" with two columns, key and value. Key is the primary key 105 | - postgresql_table: 106 | database: my_app 107 | name: config 108 | state: present 109 | columns: 110 | - { 111 | name: key, 112 | type: text, 113 | null: False 114 | } 115 | - { 116 | name: value, 117 | type: text, 118 | null: False 119 | } 120 | primary_key: 121 | - key 122 | 123 | # Ensure that the config table is not present 124 | - postgresql_table: 125 | database: my_app 126 | name: config 127 | state: absent 128 | 129 | ''' 130 | 131 | RETURN = ''' 132 | table: 133 | description: Name of the table 134 | type: str 135 | schema: 136 | description: Schema of the table 137 | type: str 138 | owner: 139 | description: Owner of the table 140 | type: str 141 | differences: 142 | description: Dictionary containing the differences between the previous table and the updated one 143 | columns: 144 | description: List containing the columns of the created table 145 | logs: 146 | description: List with logs of the operations done by the module 147 | ''' 148 | 149 | 150 | def run_module(): 151 | module_args = dict( 152 | login_user=dict(default="postgres"), 153 | login_password=dict(default="", no_log=True), 154 | login_host=dict(default=""), 155 | login_unix_socket=dict(default=""), 156 | port=dict(default="5432"), 157 | name=dict(required=True), 158 | schema=dict(default="public"), 159 | owner=dict(default=""), 160 | database=dict(default="postgres"), 161 | state=dict(default="present", choices=["absent", "present"]), 162 | columns=dict(default=[]), 163 | primary_key=dict(default=[]) 164 | ) 165 | 166 | module = AnsibleModule( 167 | argument_spec=module_args, 168 | supports_check_mode=True 169 | ) 170 | 171 | columns = ast.literal_eval(module.params["columns"]) 172 | database = module.params["database"] 173 | name = module.params["name"] 174 | owner = module.params["owner"] 175 | primary_key = ast.literal_eval(module.params["primary_key"]) 176 | schema = module.params["schema"] 177 | state = module.params["state"] 178 | 179 | if not postgresqldb_found: 180 | module.fail_json(msg="the python psycopg2 module is required") 181 | 182 | idx = 1 183 | for col in columns: 184 | if 'name' not in col.keys(): 185 | module.fail_json(msg="Missing name in column definition number %d" % idx) 186 | 187 | if 'type' not in col.keys(): 188 | module.fail_json(msg="Missing type in column definition number %d" % idx) 189 | 190 | if 'null' in col.keys() and col['null'] not in [True, False]: 191 | module.fail_json(msg="Column [%s] null key should be a boolean value" % col['name'], a=col) 192 | 193 | idx += 1 194 | 195 | if state == "present" and len(columns) == 0: 196 | module.fail_json(msg="No columns given for table [%s.%s]" % (schema, name)) 197 | 198 | cursor = None 199 | try: 200 | cursor = connect(database, prepare_connection_params(module.params)) 201 | diff = {} 202 | table_checks = table_matches(cursor, schema, name, owner, columns, primary_key, diff) 203 | logs = [] 204 | 205 | if module.check_mode: 206 | if state == "absent": 207 | changed = diff['exists'] 208 | else: 209 | # state == "present" 210 | changed = not table_checks 211 | 212 | module.exit_json( 213 | changed=changed, 214 | table=name, 215 | schema=schema, 216 | owner=owner, 217 | differences=diff, 218 | columns=columns 219 | ) 220 | 221 | cursor.connection.autocommit = False 222 | changed = False 223 | 224 | if state == "absent" and diff['exists']: 225 | cursor.execute( 226 | sql.SQL("DROP TABLE {schema}.{name}").format( 227 | schema=sql.Identifier(schema), 228 | name=sql.Identifier(name) 229 | ) 230 | ) 231 | logs.append("drop table") 232 | changed = True 233 | 234 | if state == "present": 235 | if not diff['exists']: 236 | cursor.execute( 237 | sql.SQL("CREATE TABLE {schema}.{name} (__dummy__field__ TEXT)").format( 238 | schema=sql.Identifier(schema), 239 | name=sql.Identifier(name) 240 | ) 241 | ) 242 | logs.append("exists") 243 | changed = True 244 | else: 245 | cursor.execute( 246 | sql.SQL("ALTER TABLE {schema}.{name} ADD COLUMN __dummy__field__ TEXT").format( 247 | schema=sql.Identifier(schema), 248 | name=sql.Identifier(name) 249 | ) 250 | ) 251 | 252 | if diff['owner']: 253 | changed = True 254 | cursor.execute( 255 | sql.SQL("ALTER TABLE {schema}.{name} OWNER TO {owner}").format( 256 | schema=sql.Identifier(schema), 257 | name=sql.Identifier(name), 258 | owner=sql.Identifier(owner) 259 | ) 260 | ) 261 | 262 | for col_to_drop, col_status in diff['existing_columns'].iteritems(): 263 | if col_status is not True: 264 | cursor.execute( 265 | sql.SQL("ALTER TABLE {schema}.{name} DROP COLUMN {col}").format( 266 | schema=sql.Identifier(schema), 267 | name=sql.Identifier(name), 268 | col=sql.Identifier(col_to_drop) 269 | ) 270 | ) 271 | logs.append("drop " + col_to_drop) 272 | changed = True 273 | 274 | for col in columns: 275 | col_status = diff['playbook_columns'][col['name']] 276 | if col_status is not True: 277 | cursor.execute( 278 | sql.SQL( 279 | "ALTER TABLE {schema}.{name} ADD COLUMN {col} %s %s" % 280 | (col['type'], 'NOT NULL' if 'null' in col.keys() and col['null'] is False else '') 281 | ).format( 282 | schema=sql.Identifier(schema), 283 | name=sql.Identifier(name), 284 | col=sql.Identifier(col['name']) 285 | ) 286 | ) 287 | logs.append("add " + col['name']) 288 | changed = True 289 | 290 | if diff['primary_key'] is not True: 291 | changed = diff['primary_key'] is None 292 | cursor.execute( 293 | sql.SQL("ALTER TABLE {schema}.{name} DROP CONSTRAINT IF EXISTS {pkname}").format( 294 | schema=sql.Identifier(schema), 295 | name=sql.Identifier(name), 296 | pkname=sql.Identifier(name + "_pkey") 297 | ) 298 | ) 299 | 300 | if len(primary_key) > 0: 301 | changed = True 302 | _pk = map(lambda c: sql.Identifier(c), primary_key) 303 | cursor.execute( 304 | sql.SQL("ALTER TABLE {schema}.{name} ADD PRIMARY KEY ({pkey})").format( 305 | schema=sql.Identifier(schema), 306 | name=sql.Identifier(name), 307 | pkey=sql.SQL(', ').join(_pk) 308 | ) 309 | ) 310 | logs.append("add primary key") 311 | 312 | cursor.execute( 313 | sql.SQL("ALTER TABLE {schema}.{name} DROP COLUMN IF EXISTS __dummy__field__").format( 314 | schema=sql.Identifier(schema), 315 | name=sql.Identifier(name) 316 | ) 317 | ) 318 | 319 | cursor.connection.commit() 320 | 321 | module.exit_json( 322 | changed=changed, 323 | table=name, 324 | schema=schema, 325 | owner=owner, 326 | differences=diff, 327 | columns=columns, 328 | logs=logs 329 | ) 330 | 331 | except psycopg2.DatabaseError: 332 | if cursor: 333 | cursor.connection.rollback() 334 | e = get_exception() 335 | module.fail_json(msg="database error: %s" % to_native(e), exception=traceback.format_exc()) 336 | 337 | if __name__ == '__main__': 338 | run_module() 339 | -------------------------------------------------------------------------------- /library/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rtshome/ansible_pgsql/a863115ea2604cdf520ef00b8cc8fd5758d8af01/library/utils/__init__.py -------------------------------------------------------------------------------- /meta/main.yml: -------------------------------------------------------------------------------- 1 | galaxy_info: 2 | author: Denis Gasparin 3 | description: > 4 | Add four modules to interact with PostgreSQL DBMS: postgresql_table, postgresql_row, postgresql_query, 5 | postgresql_command. 6 | company: Smart Solutions 7 | license: BSD 8 | 9 | min_ansible_version: 2.3 10 | galaxy_tags: 11 | - database 12 | - postgresql 13 | - pgsql 14 | - psql 15 | - query 16 | - sql 17 | - command 18 | - table 19 | platforms: 20 | - name: EL 21 | versions: 22 | - all 23 | - name: Fedora 24 | versions: 25 | - all 26 | - name: Debian 27 | versions: 28 | - all 29 | - name: Ubuntu 30 | versions: 31 | - all 32 | - name: SuSE 33 | versions: 34 | - all 35 | dependencies: [] 36 | -------------------------------------------------------------------------------- /module_utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rtshome/ansible_pgsql/a863115ea2604cdf520ef00b8cc8fd5758d8af01/module_utils/__init__.py -------------------------------------------------------------------------------- /module_utils/connection.py: -------------------------------------------------------------------------------- 1 | import psycopg2 2 | import psycopg2.extras 3 | 4 | 5 | def prepare_connection_params(params): 6 | params_map = { 7 | "login_host":"host", 8 | "login_user":"user", 9 | "login_password":"password", 10 | "port":"port" 11 | } 12 | kw = dict((params_map[k], v) for (k, v) in params.items() if k in params_map and v != '') 13 | 14 | # If a login_unix_socket is specified, incorporate it here. 15 | is_localhost = "host" not in kw or kw["host"] == "" or kw["host"] == "localhost" 16 | if is_localhost and params["login_unix_socket"] != "": 17 | kw["host"] = params["login_unix_socket"] 18 | 19 | return kw 20 | 21 | 22 | def connect(database, params): 23 | db_connection = psycopg2.connect(database=database, **params) 24 | # Enable autocommit so we can create databases 25 | if psycopg2.__version__ >= '2.4.2': 26 | db_connection.autocommit = True 27 | else: 28 | db_connection.set_isolation_level(psycopg2 29 | .extensions 30 | .ISOLATION_LEVEL_AUTOCOMMIT) 31 | cursor = db_connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor) 32 | return cursor 33 | -------------------------------------------------------------------------------- /module_utils/table.py: -------------------------------------------------------------------------------- 1 | def _table_exists_query(): 2 | return """ 3 | SELECT c.oid, n.nspname as "Schema", 4 | c.relname as "Name", 5 | pg_catalog.pg_get_userbyid(c.relowner) as "Owner" 6 | FROM pg_catalog.pg_class c 7 | LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace 8 | WHERE 9 | n.nspname ~ ('^(' || %s || ')$') 10 | AND c.relname ~ ('^(' || %s || ')$') 11 | AND c.relkind = 'r' 12 | AND pg_catalog.pg_table_is_visible(c.oid) 13 | ORDER BY 1,2; 14 | """ 15 | 16 | 17 | def _table_columns_definition(cursor, table_oid): 18 | cursor.execute( 19 | """ 20 | SELECT a.attname, 21 | pg_catalog.format_type(a.atttypid, a.atttypmod), 22 | (SELECT substring(pg_catalog.pg_get_expr(d.adbin, d.adrelid) for 128) 23 | FROM pg_catalog.pg_attrdef d 24 | WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum AND a.atthasdef), 25 | a.attnotnull, a.attnum, 26 | (SELECT c.collname FROM pg_catalog.pg_collation c, pg_catalog.pg_type t 27 | WHERE c.oid = a.attcollation AND t.oid = a.atttypid AND a.attcollation <> t.typcollation) AS attcollation, 28 | NULL AS indexdef, 29 | NULL AS attfdwoptions 30 | FROM pg_catalog.pg_attribute a 31 | WHERE a.attrelid = %s AND a.attnum > 0 AND NOT a.attisdropped 32 | ORDER BY a.attnum; 33 | """, 34 | (table_oid,) 35 | ) 36 | return cursor.fetchall() 37 | 38 | 39 | def _normalize_column_types(t): 40 | return t.lower() 41 | 42 | 43 | def _compare_column(db_column, playbook_columns, diff): 44 | result = True 45 | column_found = False 46 | same_type = False 47 | same_null = False 48 | for c in playbook_columns: 49 | if db_column['attname'] == c['name']: 50 | column_found = True 51 | if db_column['format_type'] == _normalize_column_types(c['type']): 52 | same_type = True 53 | if 'null' in c.keys() and c['null'] is False and db_column['attnotnull']: 54 | same_null = True 55 | elif ('null' not in c.keys() or c['null'] is True) and not db_column['attnotnull']: 56 | same_null = True 57 | 58 | diff['found'] = column_found 59 | diff['type'] = same_type 60 | diff['null'] = same_null 61 | 62 | return result and column_found and same_type and same_null 63 | 64 | 65 | def _get_primary_key(cursor, table_oid): 66 | cursor.execute( 67 | """ 68 | SELECT c2.relname, i.indisprimary, i.indisunique, i.indisclustered, i.indisvalid, pg_catalog.pg_get_indexdef(i.indexrelid, 0, true), 69 | pg_catalog.pg_get_constraintdef(con.oid, true), contype, condeferrable, condeferred, i.indisreplident, c2.reltablespace 70 | FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, pg_catalog.pg_index i 71 | LEFT JOIN pg_catalog.pg_constraint con ON (conrelid = i.indrelid AND conindid = i.indexrelid AND contype IN ('p','u','x')) 72 | WHERE c.oid = %s AND c.oid = i.indrelid AND i.indexrelid = c2.oid AND i.indisprimary = TRUE 73 | ORDER BY i.indisprimary DESC, i.indisunique DESC, c2.relname; 74 | """, 75 | (table_oid,) 76 | ) 77 | if cursor.rowcount != 1: 78 | return False 79 | return cursor.fetchone()['pg_get_constraintdef'] 80 | 81 | 82 | def _build_primary_key_def(columns): 83 | return 'PRIMARY KEY (' + ', '.join(columns) + ')' 84 | 85 | 86 | def table_exists(cursor, schema, name): 87 | cursor.execute(_table_exists_query(), (schema, name)) 88 | return cursor.rowcount == 1 89 | 90 | 91 | def table_matches(cursor, schema, name, owner, columns, primary_key, diff): 92 | diff['exists'] = None 93 | diff['owner'] = None 94 | diff['playbook_columns'] = {} 95 | diff['existing_columns'] = {} 96 | diff['logs'] = {} 97 | diff['primary_key'] = None 98 | for c in columns: 99 | diff['playbook_columns'][c['name']] = None 100 | 101 | if not table_exists(cursor, schema, name): 102 | diff['exists'] = False 103 | return False 104 | diff['exists'] = True 105 | 106 | cursor.execute(_table_exists_query(), (schema, name)) 107 | r = cursor.fetchone() 108 | table_oid = r['oid'] 109 | 110 | diff['owner'] = r['Owner'] != owner and len(owner) > 0 111 | 112 | result = True 113 | for r in _table_columns_definition(cursor, table_oid): 114 | diff['existing_columns'][r['attname']] = None 115 | col_diff = {} 116 | col_comparison = _compare_column(r, columns, col_diff) 117 | if not col_comparison: 118 | if col_diff['found']: 119 | diff['existing_columns'][r['attname']] = False 120 | diff['playbook_columns'][r['attname']] = False 121 | diff['logs'][r['attname']] = col_diff 122 | else: 123 | diff['existing_columns'][r['attname']] = True 124 | diff['playbook_columns'][r['attname']] = True 125 | result = result and col_comparison 126 | 127 | current_primary_key = _get_primary_key(cursor, table_oid) 128 | if current_primary_key is False and len(primary_key) > 0: 129 | diff['primary_key'] = False 130 | result = False 131 | elif current_primary_key is not False and len(primary_key) == 0: 132 | diff['primary_key'] = None 133 | result = False 134 | elif current_primary_key != _build_primary_key_def(primary_key): 135 | diff['primary_key'] = False 136 | result = False 137 | else: 138 | diff['primary_key'] = True 139 | 140 | return result 141 | 142 | -------------------------------------------------------------------------------- /tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | -------------------------------------------------------------------------------- /tests/inventory: -------------------------------------------------------------------------------- 1 | localhost 2 | 3 | -------------------------------------------------------------------------------- /tests/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: localhost 3 | remote_user: root 4 | roles: 5 | - pgsql -------------------------------------------------------------------------------- /vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # vars file for pgsql --------------------------------------------------------------------------------