├── .gitignore ├── ansible.cfg ├── inventory.yml ├── playbooks ├── module_utils │ ├── scramp │ │ ├── __init__.py │ │ ├── utils.py │ │ ├── LICENSE │ │ └── _version.py │ ├── pg8000 │ │ ├── exceptions.py │ │ ├── LICENSE │ │ ├── __init__.py │ │ └── native.py │ └── postgres.py ├── library │ ├── postgresql_ping.py │ ├── postgresql_schema.py │ ├── postgresql_slot.py │ ├── postgresql_query.py │ ├── postgresql_membership.py │ ├── postgresql_lang.py │ ├── postgresql_copy.py │ ├── postgresql_ext.py │ ├── postgresql_set.py │ ├── postgresql_owner.py │ ├── postgresql_tablespace.py │ └── postgresql_idx.py └── test.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /*pass* 2 | /originals/** 3 | /playbooks/connection_plugins/** 4 | **/__pycache__/** 5 | 6 | -------------------------------------------------------------------------------- /ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | stdout_callback = yaml 3 | callable_plugins = yaml,profile_tasks 4 | callback_whitelist = profile_tasks 5 | -------------------------------------------------------------------------------- /inventory.yml: -------------------------------------------------------------------------------- 1 | --- 2 | all: 3 | children: 4 | databases: 5 | hosts: 6 | localhost: 7 | ansible_connection: local 8 | ansible_python_interpreter: /usr/bin/python3 9 | 10 | -------------------------------------------------------------------------------- /playbooks/module_utils/scramp/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | from .core import ( 4 | ScramClient, ScramException, ScramMechanism, make_channel_binding) 5 | 6 | __all__ = [ScramClient, ScramMechanism, ScramException, make_channel_binding] 7 | -------------------------------------------------------------------------------- /playbooks/module_utils/scramp/utils.py: -------------------------------------------------------------------------------- 1 | import hmac as hmaca 2 | from base64 import b64decode, b64encode 3 | 4 | 5 | def hmac(hf, key, msg): 6 | return hmaca.new(key, msg=msg, digestmod=hf).digest() 7 | 8 | 9 | def h(hf, msg): 10 | return hf(msg).digest() 11 | 12 | 13 | def hi(hf, password, salt, iterations): 14 | u = ui = hmac(hf, password, salt + b'\x00\x00\x00\x01') 15 | for i in range(iterations - 1): 16 | ui = hmac(hf, password, ui) 17 | u = xor(u, ui) 18 | return u 19 | 20 | 21 | def xor(bytes1, bytes2): 22 | return bytes(a ^ b for a, b in zip(bytes1, bytes2)) 23 | 24 | 25 | def b64enc(binary): 26 | return b64encode(binary).decode('utf8') 27 | 28 | 29 | def b64dec(string): 30 | return b64decode(string) 31 | 32 | 33 | def uenc(string): 34 | return string.encode('utf-8') 35 | -------------------------------------------------------------------------------- /playbooks/module_utils/pg8000/exceptions.py: -------------------------------------------------------------------------------- 1 | class Error(Exception): 2 | """Generic exception that is the base exception of all other error 3 | exceptions. 4 | 5 | This exception is part of the `DBAPI 2.0 specification 6 | `_. 7 | """ 8 | 9 | pass 10 | 11 | 12 | class InterfaceError(Error): 13 | """Generic exception raised for errors that are related to the database 14 | interface rather than the database itself. For example, if the interface 15 | attempts to use an SSL connection but the server refuses, an InterfaceError 16 | will be raised. 17 | 18 | This exception is part of the `DBAPI 2.0 specification 19 | `_. 20 | """ 21 | 22 | pass 23 | 24 | 25 | class DatabaseError(Error): 26 | """Generic exception raised for errors that are related to the database. 27 | 28 | This exception is part of the `DBAPI 2.0 specification 29 | `_. 30 | """ 31 | 32 | pass 33 | -------------------------------------------------------------------------------- /playbooks/module_utils/scramp/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tony Locke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /playbooks/module_utils/pg8000/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2007-2009, Mathieu Fenniak 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | * The name of the author may not be used to endorse or promote products 14 | derived from this software without specific prior written permission. 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 19 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 20 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 21 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 22 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 23 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 24 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 25 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | POSSIBILITY OF SUCH DAMAGE. 27 | 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # English 2 | 3 | ## What is it? 4 | 5 | Ansible modules for PostgreSQL - plain drop-in for your project. 6 | 7 | ## What is this for? 8 | 9 | If you ever had to install psycopg2, you've definitely came across that dependency hell: libraries, libraries and libraries - tons of them. This project nulls out said dependency hassle: everything works out-of-the-box, you don't have to install anything. The guest star here is "pg8000": pure-Python PostgreSQL driver, with minor modifications to support its loading and usage by Ansible. 10 | 11 | ## Requirements 12 | 13 | * CentOS/RHEL 7.x; 14 | * Ansible >=2.9.x; 15 | * Python >=3.6.x; 16 | * PostgreSQL >=10.x (this was debugged with Postgres 12, though) 17 | 18 | ## Quick start guide 19 | 20 | 1. Copy to your project directory: 21 | 1. playbooks/library/* 22 | 2. playbooks/module_utils/pg8000/* 23 | 3. playbooks/module_utils/scramp/* 24 | 25 | 2. You're done, please don't forget to perform a wild dance (well, latter was a joke, just in case :-) 26 | 27 | ## How to send a donation to the author 28 | 29 | If you want to thank the author - [this is a donate link](https://yoomoney.ru/to/410011277351108). Any sum is happily accepted. 30 | 31 | ## Legal information 32 | 33 | This project is conceived and performed by me, Sergey Pechenko, on my own will, out of working hours, using own hardware. 34 | 35 | Copyright: (С) 2021, Sergey Pechenko 36 | 37 | This project includes the changes in the "postgres.py" Ansible module, which has own copyright: 38 | 39 | (c) Ted Timmons , 2017 (BSD-licensed) 40 | 41 | Other Ansible modules included with or without my changes carry their own licenses and copyright - please see the exact module for author names 42 | 43 | This project also uses MIT-licensed components as follows: 44 | 45 | * pg8000 (c) 2007-2009, Mathieu Fenniak 46 | 47 | * scramp (C) 2019 Tony Locke 48 | 49 | Portions for these components that provide possibility for Ansible to load and run them are also (C) 2021, Sergey Pechenko. 50 | 51 | ## License 52 | 53 | GPLv3+ (please see LICENSE) 54 | 55 | ## Contact 56 | 57 | You can ask your questions about this plugin at the [Ansible chat](https://t.me/pro_ansible), or [PM me](https://t.me/tnt4brain) 58 | 59 | 60 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_ping.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2018, Andrew Klychkov (@Andersson007) 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | 7 | # Contribution: 8 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 9 | # Welcome to https://t.me/pro_ansible for discussion and support 10 | # License: please see above 11 | 12 | from __future__ import absolute_import, division, print_function 13 | __metaclass__ = type 14 | 15 | ANSIBLE_METADATA = { 16 | 'metadata_version': '1.1', 17 | 'status': ['preview'], 18 | 'supported_by': 'community' 19 | } 20 | 21 | DOCUMENTATION = r''' 22 | --- 23 | module: postgresql_ping 24 | short_description: Check remote PostgreSQL server availability 25 | description: 26 | - Simple module to check remote PostgreSQL server availability. 27 | version_added: '2.8' 28 | options: 29 | db: 30 | description: 31 | - Name of a database to connect to. 32 | type: str 33 | aliases: 34 | - login_db 35 | seealso: 36 | - module: postgresql_info 37 | author: 38 | - Andrew Klychkov (@Andersson007) 39 | extends_documentation_fragment: postgres 40 | ''' 41 | 42 | EXAMPLES = r''' 43 | # PostgreSQL ping dbsrv server from the shell: 44 | # ansible dbsrv -m postgresql_ping 45 | 46 | # In the example below you need to generate certificates previously. 47 | # See https://www.postgresql.org/docs/current/libpq-ssl.html for more information. 48 | - name: PostgreSQL ping dbsrv server using not default credentials and ssl 49 | postgresql_ping: 50 | db: protected_db 51 | login_host: dbsrv 52 | login_user: secret 53 | login_password: secret_pass 54 | ca_cert: /root/root.crt 55 | ssl_mode: verify-full 56 | ''' 57 | 58 | RETURN = r''' 59 | is_available: 60 | description: PostgreSQL server availability. 61 | returned: always 62 | type: bool 63 | sample: true 64 | server_version: 65 | description: PostgreSQL server version. 66 | returned: always 67 | type: dict 68 | sample: { major: 10, minor: 1 } 69 | ''' 70 | 71 | 72 | from ansible.module_utils.basic import AnsibleModule 73 | from ansible.module_utils.postgres import ( 74 | connect_to_db, 75 | exec_sql, 76 | get_conn_params, 77 | postgres_common_argument_spec, 78 | ) 79 | 80 | 81 | # =========================================== 82 | # PostgreSQL module specific support methods. 83 | # 84 | 85 | 86 | class PgPing(object): 87 | def __init__(self, module, cursor): 88 | self.module = module 89 | self.cursor = cursor 90 | self.is_available = False 91 | self.version = {} 92 | 93 | def do(self): 94 | self.get_pg_version() 95 | return (self.is_available, self.version) 96 | 97 | def get_pg_version(self): 98 | query = "SELECT version()" 99 | raw = exec_sql(self, query, add_to_executed=False)[0][0] 100 | if raw: 101 | self.is_available = True 102 | raw = raw.split()[1].split('.') 103 | self.version = dict( 104 | major=int(raw[0]), 105 | minor=int(raw[1]), 106 | ) 107 | 108 | 109 | # =========================================== 110 | # Module execution. 111 | # 112 | 113 | 114 | def main(): 115 | argument_spec = postgres_common_argument_spec() 116 | argument_spec.update( 117 | db=dict(type='str', aliases=['login_db']), 118 | ) 119 | module = AnsibleModule( 120 | argument_spec=argument_spec, 121 | supports_check_mode=True, 122 | ) 123 | 124 | # Set some default values: 125 | cursor = False 126 | db_connection = False 127 | result = dict( 128 | changed=False, 129 | is_available=False, 130 | server_version=dict(), 131 | ) 132 | 133 | conn_params = get_conn_params(module, module.params, warn_db_default=False) 134 | db_connection = connect_to_db(module, conn_params, fail_on_conn=False) 135 | 136 | if db_connection is not None: 137 | cursor = db_connection.cursor() 138 | 139 | # Do job: 140 | pg_ping = PgPing(module, cursor) 141 | if cursor: 142 | # If connection established: 143 | result["is_available"], result["server_version"] = pg_ping.do() 144 | db_connection.rollback() 145 | 146 | module.exit_json(**result) 147 | 148 | 149 | if __name__ == '__main__': 150 | main() 151 | -------------------------------------------------------------------------------- /playbooks/module_utils/pg8000/__init__.py: -------------------------------------------------------------------------------- 1 | from .legacy import ( 2 | BIGINTEGER, 3 | BINARY, 4 | BOOLEAN, 5 | BOOLEAN_ARRAY, 6 | BYTES, 7 | Binary, 8 | CHAR, 9 | CHAR_ARRAY, 10 | Connection, 11 | Cursor, 12 | DATE, 13 | DATETIME, 14 | DECIMAL, 15 | DECIMAL_ARRAY, 16 | DataError, 17 | DatabaseError, 18 | Date, 19 | DateFromTicks, 20 | Error, 21 | FLOAT, 22 | FLOAT_ARRAY, 23 | INET, 24 | INT2VECTOR, 25 | INTEGER, 26 | INTEGER_ARRAY, 27 | INTERVAL, 28 | IntegrityError, 29 | InterfaceError, 30 | InternalError, 31 | JSON, 32 | JSONB, 33 | MACADDR, 34 | NAME, 35 | NAME_ARRAY, 36 | NULLTYPE, 37 | NUMBER, 38 | NotSupportedError, 39 | OID, 40 | OperationalError, 41 | PGInterval, 42 | ProgrammingError, 43 | ROWID, 44 | STRING, 45 | TEXT, 46 | TEXT_ARRAY, 47 | TIME, 48 | TIMEDELTA, 49 | TIMESTAMP, 50 | TIMESTAMPTZ, 51 | Time, 52 | TimeFromTicks, 53 | Timestamp, 54 | TimestampFromTicks, 55 | UNKNOWN, 56 | UUID_TYPE, 57 | VARCHAR, 58 | VARCHAR_ARRAY, 59 | Warning, 60 | XID, 61 | pginterval_in, 62 | pginterval_out, 63 | timedelta_in, 64 | ) 65 | 66 | 67 | 68 | from ._version import get_versions 69 | 70 | __version__ = get_versions()["version"] 71 | del get_versions 72 | 73 | # Copyright (c) 2007-2009, Mathieu Fenniak 74 | # Copyright (c) The Contributors 75 | # All rights reserved. 76 | # 77 | # Redistribution and use in source and binary forms, with or without 78 | # modification, are permitted provided that the following conditions are 79 | # met: 80 | # 81 | # * Redistributions of source code must retain the above copyright notice, 82 | # this list of conditions and the following disclaimer. 83 | # * Redistributions in binary form must reproduce the above copyright notice, 84 | # this list of conditions and the following disclaimer in the documentation 85 | # and/or other materials provided with the distribution. 86 | # * The name of the author may not be used to endorse or promote products 87 | # derived from this software without specific prior written permission. 88 | # 89 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 90 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 91 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 92 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 93 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 94 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 95 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 96 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 97 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 98 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 99 | # POSSIBILITY OF SUCH DAMAGE. 100 | 101 | __author__ = "Mathieu Fenniak" 102 | 103 | # Contribution: 104 | # pg8000 driver adaptation for Ansible drop-in 105 | # (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 106 | # Welcome to https://t.me/pro_ansible for discussion and support 107 | # License: please see above 108 | 109 | __authors__ = ["Mathieu Fenniak", "Sergey Pechenko"] 110 | 111 | def connect( 112 | user, 113 | host="localhost", 114 | database=None, 115 | port=5432, 116 | password=None, 117 | source_address=None, 118 | unix_sock=None, 119 | ssl_context=None, 120 | timeout=None, 121 | tcp_keepalive=True, 122 | application_name=None, 123 | replication=None, 124 | ): 125 | 126 | return Connection( 127 | user, 128 | host=host, 129 | database=database, 130 | port=port, 131 | password=password, 132 | source_address=source_address, 133 | unix_sock=unix_sock, 134 | ssl_context=ssl_context, 135 | timeout=timeout, 136 | tcp_keepalive=tcp_keepalive, 137 | application_name=application_name, 138 | replication=replication, 139 | ) 140 | 141 | 142 | apilevel = "2.0" 143 | """The DBAPI level supported, currently "2.0". 144 | 145 | This property is part of the `DBAPI 2.0 specification 146 | `_. 147 | """ 148 | 149 | threadsafety = 1 150 | """Integer constant stating the level of thread safety the DBAPI interface 151 | supports. This DBAPI module supports sharing of the module only. Connections 152 | and cursors my not be shared between threads. This gives pg8000 a threadsafety 153 | value of 1. 154 | 155 | This property is part of the `DBAPI 2.0 specification 156 | `_. 157 | """ 158 | 159 | paramstyle = "format" 160 | 161 | 162 | __all__ = [ 163 | "BIGINTEGER", 164 | "BINARY", 165 | "BOOLEAN", 166 | "BOOLEAN_ARRAY", 167 | "BYTES", 168 | "Binary", 169 | "CHAR", 170 | "CHAR_ARRAY", 171 | "Connection", 172 | "Cursor", 173 | "DATE", 174 | "DATETIME", 175 | "DECIMAL", 176 | "DECIMAL_ARRAY", 177 | "DataError", 178 | "DatabaseError", 179 | "Date", 180 | "DateFromTicks", 181 | "Error", 182 | "FLOAT", 183 | "FLOAT_ARRAY", 184 | "INET", 185 | "INT2VECTOR", 186 | "INTEGER", 187 | "INTEGER_ARRAY", 188 | "INTERVAL", 189 | "IntegrityError", 190 | "InterfaceError", 191 | "InternalError", 192 | "JSON", 193 | "JSONB", 194 | "MACADDR", 195 | "NAME", 196 | "NAME_ARRAY", 197 | "NULLTYPE", 198 | "NUMBER", 199 | "NotSupportedError", 200 | "OID", 201 | "OperationalError", 202 | "PGInterval", 203 | "ProgrammingError", 204 | "ROWID", 205 | "STRING", 206 | "TEXT", 207 | "TEXT_ARRAY", 208 | "TIME", 209 | "TIMEDELTA", 210 | "TIMESTAMP", 211 | "TIMESTAMPTZ", 212 | "Time", 213 | "TimeFromTicks", 214 | "Timestamp", 215 | "TimestampFromTicks", 216 | "UNKNOWN", 217 | "UUID_TYPE", 218 | "VARCHAR", 219 | "VARCHAR_ARRAY", 220 | "Warning", 221 | "XID", 222 | "connect", 223 | "pginterval_in", 224 | "pginterval_out", 225 | "timedelta_in", 226 | ] 227 | -------------------------------------------------------------------------------- /playbooks/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: all 3 | gather_facts: no 4 | become_user: postgres 5 | become: true 6 | tasks: [] 7 | # - name: Create database 8 | # postgresql_db: 9 | # name: "icinga2" 10 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 11 | # - name: "Create functions (loop)" 12 | # postgresql_query: 13 | # db: "icinga2" 14 | # query: "{{ item }}" 15 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 16 | # with_items: 17 | # - "DROP FUNCTION IF EXISTS from_unixtime(bigint);" 18 | # - > 19 | # CREATE FUNCTION from_unixtime(bigint) RETURNS timestamp AS $$ 20 | # SELECT to_timestamp($1) AT TIME ZONE 'UTC' AS result 21 | # $$ LANGUAGE sql; 22 | # - "DROP FUNCTION IF EXISTS unix_timestamp(timestamp WITH TIME ZONE);" 23 | # - > 24 | # CREATE OR REPLACE FUNCTION unix_timestamp(timestamp) RETURNS bigint AS ' 25 | # SELECT CAST(EXTRACT(EPOCH FROM $1) AS bigint) AS result; 26 | # ' LANGUAGE sql; 27 | # - > 28 | # CREATE OR REPLACE FUNCTION updatedbversion(version_i TEXT) RETURNS void AS $$ 29 | # BEGIN 30 | # IF EXISTS( SELECT * FROM icinga_dbversion WHERE name='idoutils') 31 | # THEN 32 | # UPDATE icinga_dbversion 33 | # SET version=version_i, modify_time=NOW() 34 | # WHERE name='idoutils'; 35 | # ELSE 36 | # INSERT INTO icinga_dbversion (dbversion_id, name, version, create_time, modify_time) VALUES ('1', 'idoutils', version_i, NOW(), NOW()); 37 | # END IF; 38 | # RETURN; 39 | # END; 40 | # $$ LANGUAGE plpgsql; 41 | # - name: "Create table #1" 42 | # postgresql_table: 43 | # name: icinga_acknowledgements 44 | # columns: 45 | # - 'acknowledgement_id bigserial' 46 | # - 'instance_id bigint default 0' 47 | # - 'entry_time timestamp' 48 | # - 'entry_time_usec INTEGER default 0' 49 | # - 'acknowledgement_type INTEGER default 0' 50 | # - 'object_id bigint default 0' 51 | # - 'state INTEGER default 0' 52 | # - "author_name TEXT default ''" 53 | # - "comment_data TEXT default ''" 54 | # - 'is_sticky INTEGER default 0' 55 | # - 'persistent_comment INTEGER default 0' 56 | # - 'notify_contacts INTEGER default 0' 57 | # - 'end_time timestamp' 58 | # - 'CONSTRAINT PK_acknowledgement_id PRIMARY KEY (acknowledgement_id)' 59 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 60 | # - name: "Create table #2" 61 | # postgresql_table: 62 | # db: icinga2 63 | # name: icinga_commands 64 | # columns: 65 | # - 'command_id bigserial' 66 | # - 'instance_id bigint default 0' 67 | # - 'config_type INTEGER default 0' 68 | # - 'object_id bigint default 0' 69 | # - "command_line TEXT default ''" 70 | # - 'config_hash varchar(64) DEFAULT NULL' 71 | # - 'CONSTRAINT PK_command_id PRIMARY KEY (command_id)' 72 | # - 'CONSTRAINT UQ_commands UNIQUE (instance_id,object_id,config_type)' 73 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 74 | # - name: "Create user 1" 75 | # postgresql_user: 76 | # name: testuser 77 | # db: icinga2 78 | # priv: ALL 79 | # password: 'THAT' 80 | # # does not work 81 | # encrypted: true 82 | # role_attr_flags: superuser 83 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 84 | # - name: "Create group 1" 85 | # postgresql_user: 86 | # name: read_only 87 | # db: icinga2 88 | # priv: ALL 89 | # role_attr_flags: NOLOGIN 90 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 91 | # When got bitten by SELinux, 92 | # set system_u:object_r:postgresql_db_t:s0 on target dir 93 | # this will fix things 94 | # - name: "Create tablespace" 95 | # postgresql_tablespace: 96 | # name: this_test 97 | # state: absent 98 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 99 | # location: /tmp/_pg_test_tablespace_ 100 | # - name: "Create sequence" 101 | # postgresql_sequence: 102 | # name: serial 103 | # start: 101 104 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 105 | # - name: "Create sequence #2" 106 | # postgresql_sequence: 107 | # name: serial 108 | # start: 101 109 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 110 | # - name: "Delete sequence #2" 111 | # postgresql_sequence: 112 | # name: serial 113 | # state: absent 114 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 115 | # - name: "Grant privileges" 116 | # postgresql_privs: 117 | # database: icinga2 118 | # state: present 119 | # privs: ALL 120 | # type: table 121 | # objs: icinga_commands 122 | # schema: public 123 | # roles: testuser 124 | # grant_option: no 125 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 126 | # - name: "Create schema" 127 | # postgresql_schema: 128 | # db: icinga2 129 | # name: synthezoid 130 | # state: present 131 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 132 | # - name: "Pingy" 133 | # postgresql_ping: 134 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 135 | # - name: "Info" 136 | # postgresql_info: 137 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 138 | # - name: "Adds pg_trgm extension" 139 | # postgresql_ext: 140 | # name: pg_trgm 141 | # db: icinga2 142 | # schema: synthezoid 143 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 144 | # - name: "Add index" 145 | # postgresql_idx: 146 | # login_unix_socket: "/var/run/postgresql/.s.PGSQL.5432" 147 | # db: icinga2 148 | # table: "icinga_commands" 149 | # idxname: "test" 150 | # columns: 151 | # - "command_id" 152 | # state: present 153 | # - name: "pg_hda.conf content" 154 | # postgresql_pg_hba: 155 | # dest: /var/opt/rh/rh-postgresql12/lib/pgsql/data/pg_hba.conf 156 | # - name: Add language plpython 157 | # postgresql_lang: 158 | # db: icinga2 159 | # lang: plpythonu 160 | # state: present 161 | # trust: yes 162 | # force_trust: yes 163 | # - name: Add slot 164 | # postgresql_slot: 165 | # slot_name: physical_gg 166 | # db: icinga2 167 | # state: present 168 | # - name: Set wal_log_hints parameter to default value (remove parameter from postgresql.auto.conf) 169 | # postgresql_set: 170 | # name: wal_log_hints 171 | # value: default 172 | # - name: Give ownership 173 | # postgresql_owner: 174 | # db: icinga2 175 | # obj_name: icinga_commands 176 | # obj_type: table 177 | # new_owner: testuser 178 | # - name: Tets publication 179 | # postgresql_publication: 180 | # db: icinga2 181 | # name: test_pub 182 | # state: present 183 | # tables: 184 | # - icinga_commands 185 | # - name: Copy data from acme table to file /tmp/data.txt in text format, TAB-separated 186 | # postgresql_copy: 187 | # src: icinga_commands 188 | # copy_to: /tmp/data.txt 189 | # - name: Grant role read_only to test user 190 | # postgresql_membership: 191 | # group: read_only 192 | # target_roles: 193 | # - testuser 194 | # state: present -------------------------------------------------------------------------------- /playbooks/module_utils/pg8000/native.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from .converters import ( 4 | BIGINT, 5 | BOOLEAN, 6 | BOOLEAN_ARRAY, 7 | BYTES, 8 | CHAR, 9 | CHAR_ARRAY, 10 | DATE, 11 | FLOAT, 12 | FLOAT_ARRAY, 13 | INET, 14 | INT2VECTOR, 15 | INTEGER, 16 | INTEGER_ARRAY, 17 | INTERVAL, 18 | JSON, 19 | JSONB, 20 | MACADDR, 21 | NAME, 22 | NAME_ARRAY, 23 | NULLTYPE, 24 | NUMERIC, 25 | NUMERIC_ARRAY, 26 | OID, 27 | PGInterval, 28 | STRING, 29 | TEXT, 30 | TEXT_ARRAY, 31 | TIME, 32 | TIMESTAMP, 33 | TIMESTAMPTZ, 34 | UNKNOWN, 35 | UUID_TYPE, 36 | VARCHAR, 37 | VARCHAR_ARRAY, 38 | XID, 39 | make_params, 40 | ) 41 | from .core import CoreConnection 42 | from .exceptions import DatabaseError, Error, InterfaceError 43 | 44 | 45 | # Copyright (c) 2007-2009, Mathieu Fenniak 46 | # Copyright (c) The Contributors 47 | # All rights reserved. 48 | # 49 | # Redistribution and use in source and binary forms, with or without 50 | # modification, are permitted provided that the following conditions are 51 | # met: 52 | # 53 | # * Redistributions of source code must retain the above copyright notice, 54 | # this list of conditions and the following disclaimer. 55 | # * Redistributions in binary form must reproduce the above copyright notice, 56 | # this list of conditions and the following disclaimer in the documentation 57 | # and/or other materials provided with the distribution. 58 | # * The name of the author may not be used to endorse or promote products 59 | # derived from this software without specific prior written permission. 60 | # 61 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 62 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 63 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 64 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 65 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 66 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 67 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 68 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 69 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 70 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 71 | # POSSIBILITY OF SUCH DAMAGE. 72 | 73 | __author__ = "Mathieu Fenniak" 74 | 75 | # Contribution: 76 | # pg8000 driver adaptation for Ansible drop-in 77 | # (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 78 | # Welcome to https://t.me/pro_ansible for discussion and support 79 | # License: please see above 80 | 81 | __authors__ = ["Mathieu Fenniak", "Sergey Pechenko"] 82 | 83 | def to_statement(query): 84 | OUTSIDE = 0 # outside quoted string 85 | INSIDE_SQ = 1 # inside single-quote string '...' 86 | INSIDE_QI = 2 # inside quoted identifier "..." 87 | INSIDE_ES = 3 # inside escaped single-quote string, E'...' 88 | INSIDE_PN = 4 # inside parameter name eg. :name 89 | INSIDE_CO = 5 # inside inline comment eg. -- 90 | 91 | in_quote_escape = False 92 | placeholders = [] 93 | output_query = [] 94 | state = OUTSIDE 95 | prev_c = None 96 | for i, c in enumerate(query): 97 | if i + 1 < len(query): 98 | next_c = query[i + 1] 99 | else: 100 | next_c = None 101 | 102 | if state == OUTSIDE: 103 | if c == "'": 104 | output_query.append(c) 105 | if prev_c == "E": 106 | state = INSIDE_ES 107 | else: 108 | state = INSIDE_SQ 109 | elif c == '"': 110 | output_query.append(c) 111 | state = INSIDE_QI 112 | elif c == "-": 113 | output_query.append(c) 114 | if prev_c == "-": 115 | state = INSIDE_CO 116 | elif c == ":" and next_c not in ":=" and prev_c != ":": 117 | state = INSIDE_PN 118 | placeholders.append("") 119 | else: 120 | output_query.append(c) 121 | 122 | elif state == INSIDE_SQ: 123 | if c == "'": 124 | if in_quote_escape: 125 | in_quote_escape = False 126 | else: 127 | if next_c == "'": 128 | in_quote_escape = True 129 | else: 130 | state = OUTSIDE 131 | output_query.append(c) 132 | 133 | elif state == INSIDE_QI: 134 | if c == '"': 135 | state = OUTSIDE 136 | output_query.append(c) 137 | 138 | elif state == INSIDE_ES: 139 | if c == "'" and prev_c != "\\": 140 | # check for escaped single-quote 141 | state = OUTSIDE 142 | output_query.append(c) 143 | 144 | elif state == INSIDE_PN: 145 | placeholders[-1] += c 146 | if next_c is None or (not next_c.isalnum() and next_c != "_"): 147 | state = OUTSIDE 148 | try: 149 | pidx = placeholders.index(placeholders[-1], 0, -1) 150 | output_query.append("$" + str(pidx + 1)) 151 | del placeholders[-1] 152 | except ValueError: 153 | output_query.append("$" + str(len(placeholders))) 154 | 155 | elif state == INSIDE_CO: 156 | output_query.append(c) 157 | if c == "\n": 158 | state = OUTSIDE 159 | 160 | prev_c = c 161 | 162 | for reserved in ("types", "stream"): 163 | if reserved in placeholders: 164 | raise InterfaceError( 165 | "The name '" + reserved + "' can't be used as a placeholder " 166 | "because it's used for another purpose." 167 | ) 168 | 169 | def make_vals(args): 170 | vals = [] 171 | for p in placeholders: 172 | try: 173 | vals.append(args[p]) 174 | except KeyError: 175 | raise InterfaceError( 176 | "There's a placeholder '" + p + "' in the query, but " 177 | "no matching keyword argument." 178 | ) 179 | return tuple(vals) 180 | 181 | return "".join(output_query), make_vals 182 | 183 | 184 | class Connection(CoreConnection): 185 | def __init__(self, *args, **kwargs): 186 | super().__init__(*args, **kwargs) 187 | self._context = None 188 | 189 | @property 190 | def columns(self): 191 | context = self._context 192 | if context is None: 193 | return None 194 | return context.columns 195 | 196 | @property 197 | def row_count(self): 198 | context = self._context 199 | if context is None: 200 | return None 201 | return context.row_count 202 | 203 | def run(self, sql, stream=None, types=None, **params): 204 | statement, make_vals = to_statement(sql) 205 | if types is None: 206 | oids = None 207 | else: 208 | oids = make_vals(defaultdict(lambda: None, types)) 209 | 210 | self._context = self.execute_unnamed( 211 | statement, make_vals(params), input_oids=oids, stream=stream 212 | ) 213 | return self._context.rows 214 | 215 | def prepare(self, sql): 216 | return PreparedStatement(self, sql) 217 | 218 | 219 | class PreparedStatement: 220 | def __init__(self, con, sql): 221 | self.con = con 222 | self.statement, self.make_vals = to_statement(sql) 223 | self.name_map = {} 224 | 225 | @property 226 | def columns(self): 227 | return self._context.columns 228 | 229 | def run(self, stream=None, **params): 230 | oids, params = make_params(self.con.py_types, self.make_vals(params)) 231 | 232 | try: 233 | name_bin, columns, input_funcs = self.name_map[oids] 234 | except KeyError: 235 | name_bin, columns, input_funcs = self.name_map[ 236 | oids 237 | ] = self.con.prepare_statement(self.statement, oids) 238 | 239 | self._context = self.con.execute_named(name_bin, params, columns, input_funcs) 240 | 241 | return self._context.rows 242 | 243 | def close(self): 244 | for statement_name_bin, _, _ in self.name_map.values(): 245 | self.con.close_prepared_statement(statement_name_bin) 246 | 247 | self.name_map.clear() 248 | 249 | 250 | __all__ = [ 251 | "BIGINT", 252 | "BOOLEAN", 253 | "BOOLEAN_ARRAY", 254 | "BYTES", 255 | "CHAR", 256 | "CHAR_ARRAY", 257 | "DATE", 258 | "DatabaseError", 259 | "Error", 260 | "FLOAT", 261 | "FLOAT_ARRAY", 262 | "INET", 263 | "INT2VECTOR", 264 | "INTEGER", 265 | "INTEGER_ARRAY", 266 | "INTERVAL", 267 | "InterfaceError", 268 | "JSON", 269 | "JSONB", 270 | "MACADDR", 271 | "NAME", 272 | "NAME_ARRAY", 273 | "NULLTYPE", 274 | "NUMERIC", 275 | "NUMERIC_ARRAY", 276 | "OID", 277 | "PGInterval", 278 | "STRING", 279 | "TEXT", 280 | "TEXT_ARRAY", 281 | "TIME", 282 | "TIMESTAMP", 283 | "TIMESTAMPTZ", 284 | "UNKNOWN", 285 | "UUID_TYPE", 286 | "VARCHAR", 287 | "VARCHAR_ARRAY", 288 | "XID", 289 | ] 290 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_schema.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2016, Ansible Project 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | 7 | # Contribution: 8 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 9 | # Welcome to https://t.me/pro_ansible for discussion and support 10 | # License: please see above 11 | 12 | from __future__ import absolute_import, division, print_function 13 | __metaclass__ = type 14 | 15 | ANSIBLE_METADATA = {'metadata_version': '1.1', 16 | 'status': ['preview'], 17 | 'supported_by': 'community'} 18 | 19 | DOCUMENTATION = r''' 20 | --- 21 | module: postgresql_schema 22 | short_description: Add or remove PostgreSQL schema 23 | description: 24 | - Add or remove PostgreSQL schema. 25 | version_added: '2.3' 26 | options: 27 | name: 28 | description: 29 | - Name of the schema to add or remove. 30 | required: true 31 | type: str 32 | aliases: 33 | - schema 34 | database: 35 | description: 36 | - Name of the database to connect to and add or remove the schema. 37 | type: str 38 | default: postgres 39 | aliases: 40 | - db 41 | - login_db 42 | owner: 43 | description: 44 | - Name of the role to set as owner of the schema. 45 | type: str 46 | session_role: 47 | version_added: '2.8' 48 | description: 49 | - Switch to session_role after connecting. 50 | - The specified session_role must be a role that the current login_user is a member of. 51 | - Permissions checking for SQL commands is carried out as though the session_role 52 | were the one that had logged in originally. 53 | type: str 54 | state: 55 | description: 56 | - The schema state. 57 | type: str 58 | default: present 59 | choices: [ absent, present ] 60 | cascade_drop: 61 | description: 62 | - Drop schema with CASCADE to remove child objects. 63 | type: bool 64 | default: false 65 | version_added: '2.8' 66 | ssl_mode: 67 | description: 68 | - Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. 69 | - See https://www.postgresql.org/docs/current/static/libpq-ssl.html for more information on the modes. 70 | - Default of C(prefer) matches libpq default. 71 | type: str 72 | default: prefer 73 | choices: [ allow, disable, prefer, require, verify-ca, verify-full ] 74 | version_added: '2.8' 75 | ca_cert: 76 | description: 77 | - Specifies the name of a file containing SSL certificate authority (CA) certificate(s). 78 | - If the file exists, the server's certificate will be verified to be signed by one of these authorities. 79 | type: str 80 | aliases: [ ssl_rootcert ] 81 | version_added: '2.8' 82 | seealso: 83 | - name: PostgreSQL schemas 84 | description: General information about PostgreSQL schemas. 85 | link: https://www.postgresql.org/docs/current/ddl-schemas.html 86 | - name: CREATE SCHEMA reference 87 | description: Complete reference of the CREATE SCHEMA command documentation. 88 | link: https://www.postgresql.org/docs/current/sql-createschema.html 89 | - name: ALTER SCHEMA reference 90 | description: Complete reference of the ALTER SCHEMA command documentation. 91 | link: https://www.postgresql.org/docs/current/sql-alterschema.html 92 | - name: DROP SCHEMA reference 93 | description: Complete reference of the DROP SCHEMA command documentation. 94 | link: https://www.postgresql.org/docs/current/sql-dropschema.html 95 | author: 96 | - Flavien Chantelot (@Dorn-) 97 | - Thomas O'Donnell (@andytom) 98 | extends_documentation_fragment: postgres 99 | ''' 100 | 101 | EXAMPLES = r''' 102 | - name: Create a new schema with name acme in test database 103 | postgresql_schema: 104 | db: test 105 | name: acme 106 | 107 | - name: Create a new schema acme with a user bob who will own it 108 | postgresql_schema: 109 | name: acme 110 | owner: bob 111 | 112 | - name: Drop schema "acme" with cascade 113 | postgresql_schema: 114 | name: acme 115 | state: absent 116 | cascade_drop: yes 117 | ''' 118 | 119 | RETURN = r''' 120 | schema: 121 | description: Name of the schema. 122 | returned: success, changed 123 | type: str 124 | sample: "acme" 125 | queries: 126 | description: List of executed queries. 127 | returned: always 128 | type: list 129 | sample: ["CREATE SCHEMA \"acme\""] 130 | ''' 131 | 132 | import traceback 133 | 134 | 135 | from ansible.module_utils.basic import AnsibleModule 136 | from ansible.module_utils.postgres import ( 137 | connect_to_db, 138 | get_conn_params, 139 | postgres_common_argument_spec, 140 | ) 141 | from ansible.module_utils.database import SQLParseError, pg_quote_identifier 142 | from ansible.module_utils._text import to_native 143 | 144 | executed_queries = [] 145 | 146 | 147 | class NotSupportedError(Exception): 148 | pass 149 | 150 | 151 | # =========================================== 152 | # PostgreSQL module specific support methods. 153 | # 154 | 155 | def set_owner(cursor, schema, owner): 156 | query = "ALTER SCHEMA %s OWNER TO %s" % ( 157 | pg_quote_identifier(schema, 'schema'), 158 | pg_quote_identifier(owner, 'role')) 159 | cursor.execute(query) 160 | executed_queries.append(query) 161 | return True 162 | 163 | 164 | def get_schema_info(cursor, schema): 165 | query = ("SELECT schema_owner AS owner " 166 | "FROM information_schema.schemata " 167 | "WHERE schema_name = (%s)") 168 | cursor.execute(query, [schema]) 169 | return cursor.fetchone() 170 | 171 | 172 | def schema_exists(cursor, schema): 173 | query = ("SELECT schema_name FROM information_schema.schemata " 174 | "WHERE schema_name = (%s)") 175 | cursor.execute(query, [schema]) 176 | return cursor.rowcount == 1 177 | 178 | 179 | def schema_delete(cursor, schema, cascade): 180 | if schema_exists(cursor, schema): 181 | query = "DROP SCHEMA %s" % pg_quote_identifier(schema, 'schema') 182 | if cascade: 183 | query += " CASCADE" 184 | cursor.execute(query) 185 | executed_queries.append(query) 186 | return True 187 | else: 188 | return False 189 | 190 | 191 | def schema_create(cursor, schema, owner): 192 | if not schema_exists(cursor, schema): 193 | query_fragments = ['CREATE SCHEMA %s' % pg_quote_identifier(schema, 'schema')] 194 | if owner: 195 | query_fragments.append('AUTHORIZATION %s' % pg_quote_identifier(owner, 'role')) 196 | query = ' '.join(query_fragments) 197 | cursor.execute(query) 198 | executed_queries.append(query) 199 | return True 200 | else: 201 | schema_info = get_schema_info(cursor, schema) 202 | if owner and owner != schema_info['owner']: 203 | return set_owner(cursor, schema, owner) 204 | else: 205 | return False 206 | 207 | 208 | def schema_matches(cursor, schema, owner): 209 | if not schema_exists(cursor, schema): 210 | return False 211 | else: 212 | schema_info = get_schema_info(cursor, schema) 213 | if owner and owner != schema_info['owner']: 214 | return False 215 | else: 216 | return True 217 | 218 | # =========================================== 219 | # Module execution. 220 | # 221 | 222 | 223 | def main(): 224 | argument_spec = postgres_common_argument_spec() 225 | argument_spec.update( 226 | schema=dict(type="str", required=True, aliases=['name']), 227 | owner=dict(type="str", default=""), 228 | database=dict(type="str", default="postgres", aliases=["db", "login_db"]), 229 | cascade_drop=dict(type="bool", default=False), 230 | state=dict(type="str", default="present", choices=["absent", "present"]), 231 | session_role=dict(type="str"), 232 | ) 233 | 234 | module = AnsibleModule( 235 | argument_spec=argument_spec, 236 | supports_check_mode=True, 237 | ) 238 | 239 | schema = module.params["schema"] 240 | owner = module.params["owner"] 241 | state = module.params["state"] 242 | cascade_drop = module.params["cascade_drop"] 243 | changed = False 244 | 245 | conn_params = get_conn_params(module, module.params) 246 | db_connection = connect_to_db(module, conn_params, autocommit=True) 247 | cursor = db_connection.cursor() 248 | 249 | try: 250 | if module.check_mode: 251 | if state == "absent": 252 | changed = not schema_exists(cursor, schema) 253 | elif state == "present": 254 | changed = not schema_matches(cursor, schema, owner) 255 | module.exit_json(changed=changed, schema=schema) 256 | 257 | if state == "absent": 258 | try: 259 | changed = schema_delete(cursor, schema, cascade_drop) 260 | except SQLParseError as e: 261 | module.fail_json(msg=to_native(e), exception=traceback.format_exc()) 262 | 263 | elif state == "present": 264 | try: 265 | changed = schema_create(cursor, schema, owner) 266 | except SQLParseError as e: 267 | module.fail_json(msg=to_native(e), exception=traceback.format_exc()) 268 | except NotSupportedError as e: 269 | module.fail_json(msg=to_native(e), exception=traceback.format_exc()) 270 | except SystemExit: 271 | # Avoid catching this on Python 2.4 272 | raise 273 | except Exception as e: 274 | module.fail_json(msg="Database query failed: %s" % to_native(e), exception=traceback.format_exc()) 275 | 276 | db_connection.close() 277 | module.exit_json(changed=changed, schema=schema, queries=executed_queries) 278 | 279 | 280 | if __name__ == '__main__': 281 | main() 282 | -------------------------------------------------------------------------------- /playbooks/module_utils/postgres.py: -------------------------------------------------------------------------------- 1 | # This code is part of Ansible, but is an independent component. 2 | # This particular file snippet, and this file snippet only, is BSD licensed. 3 | # Modules you write using this snippet, which is embedded dynamically by Ansible 4 | # still belong to the author of the module, and may assign their own license 5 | # to the complete work. 6 | # 7 | # Copyright (c), Ted Timmons , 2017. 8 | # Most of this was originally added by other creators in the postgresql_user module. 9 | # All rights reserved. 10 | # 11 | # Redistribution and use in source and binary forms, with or without modification, 12 | # are permitted provided that the following conditions are met: 13 | # 14 | # * Redistributions of source code must retain the above copyright 15 | # notice, this list of conditions and the following disclaimer. 16 | # * Redistributions in binary form must reproduce the above copyright notice, 17 | # this list of conditions and the following disclaimer in the documentation 18 | # and/or other materials provided with the distribution. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 23 | # IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 24 | # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 25 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 26 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE 28 | # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | # Contribution: 31 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 32 | # Welcome to https://t.me/pro_ansible for discussion and support 33 | # License: please see above 34 | 35 | pg8k = None # This line needs for unit tests (?) 36 | try: 37 | from . import pg8000 38 | 39 | HAS_PG8K = True 40 | pg8000.paramstyle = 'pyformat' 41 | DataError = pg8000.DataError 42 | DatabaseError = pg8000.DatabaseError 43 | IntegrityError = pg8000.IntegrityError 44 | InterfaceError = pg8000.InterfaceError 45 | InternalError = pg8000.InternalError 46 | NotSupportedError = pg8000.NotSupportedError 47 | OperationalError = pg8000.OperationalError 48 | ProgrammingError = pg8000.ProgrammingError 49 | except ImportError: 50 | HAS_PG8K = False 51 | 52 | from ansible.module_utils.basic import missing_required_lib 53 | from ansible.module_utils._text import to_native 54 | from ansible.module_utils.six import iteritems 55 | from distutils.version import LooseVersion 56 | 57 | 58 | def dict_wrap(cursor: pg8000.Cursor, values: list): 59 | return dict(zip([x[0] for x in cursor.description], values)) 60 | 61 | 62 | def postgres_common_argument_spec(): 63 | """ 64 | Return a dictionary with connection options. 65 | 66 | The options are commonly used by most of PostgreSQL modules. 67 | """ 68 | return dict( 69 | login_user=dict(default='postgres'), 70 | login_password=dict(default=''), # no_log=True), 71 | login_host=dict(default=''), 72 | login_unix_socket=dict(default=''), 73 | port=dict(type='int', default=5432, aliases=['login_port']), 74 | ssl_mode=dict(default='prefer', choices=['allow', 'disable', 'prefer', 'require', 'verify-ca', 'verify-full']), 75 | ca_cert=dict(aliases=['ssl_rootcert']), 76 | ) 77 | 78 | 79 | def ensure_required_libs(module): 80 | """Check required libraries.""" 81 | if not HAS_PG8K: 82 | module.fail_json(msg=missing_required_lib('pg8000')) 83 | # broken at the moment 84 | # if module.params.get('ca_cert') and LooseVersion(psycopg2.__version__) < LooseVersion('2.4.3'): 85 | # module.fail_json(msg='psycopg2 must be at least 2.4.3 in order to use the ca_cert parameter') 86 | 87 | 88 | def connect_to_db(module, conn_params, autocommit=False, fail_on_conn=True): 89 | """Connect to a PostgreSQL database. 90 | 91 | Return pg8000 connection object. 92 | 93 | Args: 94 | module (AnsibleModule) -- object of ansible.module_utils.basic.AnsibleModule class 95 | conn_params (dict) -- dictionary with connection parameters 96 | 97 | Kwargs: 98 | autocommit (bool) -- commit automatically (default False) 99 | fail_on_conn (bool) -- fail if connection failed or just warn and return None (default True) 100 | """ 101 | ensure_required_libs(module) 102 | 103 | db_connection = None 104 | try: 105 | del conn_params['sslmode'] 106 | db_connection = pg8000.connect(**conn_params) 107 | if autocommit: 108 | db_connection.autocommit = True 109 | version_tuple_str = [tple[1] for tple in db_connection.parameter_statuses if tple[0] == b'server_version'][ 110 | 0].decode('utf-8') 111 | version_list = (version_tuple_str.split('.') + [0])[0:3] 112 | db_connection.server_version = int(''.join(["{:02}".format(int(x)) for x in version_list])) 113 | # Switch role, if specified: 114 | if module.params.get('session_role'): 115 | cursor = db_connection.cursor() 116 | try: 117 | cursor.execute('SET ROLE %s' % module.params['session_role']) 118 | except Exception as e: 119 | module.fail_json(msg="Could not switch role: %s" % to_native(e)) 120 | finally: 121 | cursor.close() 122 | 123 | except TypeError as e: 124 | if 'sslrootcert' in e.args[0]: 125 | module.fail_json(msg='Postgresql server must be at least ' 126 | 'version 8.4 to support sslrootcert') 127 | 128 | if fail_on_conn: 129 | module.fail_json(msg="unable to connect to database: %s" % to_native(e)) 130 | else: 131 | module.warn("PostgreSQL server is unavailable: %s" % to_native(e)) 132 | db_connection = None 133 | 134 | except Exception as e: 135 | if fail_on_conn: 136 | module.fail_json(msg="unable to connect to database: %s" % to_native(e)) 137 | else: 138 | module.warn("PostgreSQL server is unavailable: %s" % to_native(e)) 139 | db_connection = None 140 | 141 | return db_connection 142 | 143 | 144 | def exec_sql(obj, query, query_params=None, ddl=False, add_to_executed=True, dont_exec=False, **kwargs): 145 | """Execute SQL. 146 | 147 | Auxiliary function for PostgreSQL user classes. 148 | 149 | Returns a query result if possible or True/False if ddl=True arg was passed. 150 | It necessary for statements that don't return any result (like DDL queries). 151 | 152 | Args: 153 | obj (obj) -- must be an object of a user class. 154 | The object must have module (AnsibleModule class object) and 155 | cursor (psycopg cursor object) attributes 156 | query (str) -- SQL query to execute 157 | 158 | Kwargs: 159 | query_params (dict or tuple) -- Query parameters to prevent SQL injections, 160 | could be a dict or tuple 161 | ddl (bool) -- must return True or False instead of rows (typical for DDL queries) 162 | (default False) 163 | add_to_executed (bool) -- append the query to obj.executed_queries attribute 164 | dont_exec (bool) -- used with add_to_executed=True to generate a query, add it 165 | to obj.executed_queries list and return True (default False) 166 | """ 167 | 168 | if dont_exec: 169 | # This is usually needed to return queries in check_mode 170 | # without execution 171 | query = obj.cursor.mogrify(query, query_params) 172 | if add_to_executed: 173 | obj.executed_queries.append(query) 174 | 175 | return True 176 | 177 | try: 178 | if query_params is not None: 179 | obj.cursor.execute(query, query_params) 180 | else: 181 | obj.cursor.execute(query) 182 | 183 | if add_to_executed: 184 | if query_params is not None: 185 | obj.executed_queries.append(query % (*query_params,)) 186 | else: 187 | obj.executed_queries.append(query) 188 | 189 | if not ddl: 190 | res = obj.cursor.fetchall() 191 | if 'hacky_dict' in kwargs: 192 | tmp_descr = (x[0] for x in obj.cursor.description) 193 | tmp_res = [dict(zip(tmp_descr, x)) for x in res] 194 | return tmp_res 195 | else: 196 | return res 197 | return True 198 | except Exception as e: 199 | obj.module.fail_json(msg="Cannot execute SQL '%s': %s" % (query, to_native(e))) 200 | return False 201 | 202 | 203 | def get_conn_params(module, params_dict, warn_db_default=True): 204 | """Get connection parameters from the passed dictionary. 205 | 206 | Return a dictionary with parameters to connect to PostgreSQL server. 207 | 208 | Args: 209 | module (AnsibleModule) -- object of ansible.module_utils.basic.AnsibleModule class 210 | params_dict (dict) -- dictionary with variables 211 | 212 | Kwargs: 213 | warn_db_default (bool) -- warn that the default DB is used (default True) 214 | """ 215 | # To use defaults values, keyword arguments must be absent, so 216 | # check which values are empty and don't include in the return dictionary 217 | params_map = { 218 | "login_host": "host", 219 | "login_user": "user", 220 | "login_password": "password", 221 | "port": "port", 222 | "ssl_mode": "sslmode", 223 | "ca_cert": "sslrootcert" 224 | } 225 | 226 | # Might be different in the modules: 227 | if params_dict.get('db'): 228 | params_map['db'] = 'database' 229 | elif params_dict.get('database'): 230 | params_map['database'] = 'database' 231 | elif params_dict.get('login_db'): 232 | params_map['login_db'] = 'database' 233 | else: 234 | if warn_db_default: 235 | module.warn('Database name has not been passed, ' 236 | 'used default database to connect to.') 237 | 238 | kw = dict((params_map[k], v) for (k, v) in iteritems(params_dict) 239 | if k in params_map and v != '' and v is not None) 240 | 241 | # If a login_unix_socket is specified, incorporate it here. 242 | is_localhost = "host" not in kw or kw["host"] is None or kw["host"] == "localhost" 243 | if is_localhost and params_dict["login_unix_socket"] != "": 244 | kw["unix_sock"] = params_dict["login_unix_socket"] 245 | 246 | return kw 247 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_slot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2019, John Scalia (@jscalia), Andrew Klychkov (@Andersson007) 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | 7 | # Contribution: 8 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 9 | # Welcome to https://t.me/pro_ansible for discussion and support 10 | # License: please see above 11 | 12 | from __future__ import absolute_import, division, print_function 13 | __metaclass__ = type 14 | 15 | ANSIBLE_METADATA = {'metadata_version': '1.1', 16 | 'status': ['preview'], 17 | 'supported_by': 'community'} 18 | 19 | DOCUMENTATION = ''' 20 | --- 21 | module: postgresql_slot 22 | short_description: Add or remove replication slots from a PostgreSQL database 23 | description: 24 | - Add or remove physical or logical replication slots from a PostgreSQL database. 25 | version_added: '2.8' 26 | 27 | options: 28 | name: 29 | description: 30 | - Name of the replication slot to add or remove. 31 | type: str 32 | required: yes 33 | aliases: 34 | - slot_name 35 | slot_type: 36 | description: 37 | - Slot type. 38 | type: str 39 | default: physical 40 | choices: [ logical, physical ] 41 | state: 42 | description: 43 | - The slot state. 44 | - I(state=present) implies the slot must be present in the system. 45 | - I(state=absent) implies the I(groups) must be revoked from I(target_roles). 46 | type: str 47 | default: present 48 | choices: [ absent, present ] 49 | immediately_reserve: 50 | description: 51 | - Optional parameter that when C(yes) specifies that the LSN for this replication slot be reserved 52 | immediately, otherwise the default, C(no), specifies that the LSN is reserved on the first connection 53 | from a streaming replication client. 54 | - Is available from PostgreSQL version 9.6. 55 | - Uses only with I(slot_type=physical). 56 | - Mutually exclusive with I(slot_type=logical). 57 | type: bool 58 | default: no 59 | output_plugin: 60 | description: 61 | - All logical slots must indicate which output plugin decoder they're using. 62 | - This parameter does not apply to physical slots. 63 | - It will be ignored with I(slot_type=physical). 64 | type: str 65 | default: "test_decoding" 66 | db: 67 | description: 68 | - Name of database to connect to. 69 | type: str 70 | aliases: 71 | - login_db 72 | session_role: 73 | description: 74 | - Switch to session_role after connecting. 75 | The specified session_role must be a role that the current login_user is a member of. 76 | - Permissions checking for SQL commands is carried out as though 77 | the session_role were the one that had logged in originally. 78 | type: str 79 | 80 | notes: 81 | - Physical replication slots were introduced to PostgreSQL with version 9.4, 82 | while logical replication slots were added beginning with version 10.0. 83 | 84 | seealso: 85 | - name: PostgreSQL pg_replication_slots view reference 86 | description: Complete reference of the PostgreSQL pg_replication_slots view. 87 | link: https://www.postgresql.org/docs/current/view-pg-replication-slots.html 88 | - name: PostgreSQL streaming replication protocol reference 89 | description: Complete reference of the PostgreSQL streaming replication protocol documentation. 90 | link: https://www.postgresql.org/docs/current/protocol-replication.html 91 | - name: PostgreSQL logical replication protocol reference 92 | description: Complete reference of the PostgreSQL logical replication protocol documentation. 93 | link: https://www.postgresql.org/docs/current/protocol-logical-replication.html 94 | 95 | author: 96 | - John Scalia (@jscalia) 97 | - Andrew Klychkov (@Andersson007) 98 | extends_documentation_fragment: postgres 99 | ''' 100 | 101 | EXAMPLES = r''' 102 | - name: Create physical_one physical slot if doesn't exist 103 | become_user: postgres 104 | postgresql_slot: 105 | slot_name: physical_one 106 | db: ansible 107 | 108 | - name: Remove physical_one slot if exists 109 | become_user: postgres 110 | postgresql_slot: 111 | slot_name: physical_one 112 | db: ansible 113 | state: absent 114 | 115 | - name: Create logical_one logical slot to the database acme if doesn't exist 116 | postgresql_slot: 117 | name: logical_slot_one 118 | slot_type: logical 119 | state: present 120 | output_plugin: custom_decoder_one 121 | db: "acme" 122 | 123 | - name: Remove logical_one slot if exists from the cluster running on another host and non-standard port 124 | postgresql_slot: 125 | name: logical_one 126 | login_host: mydatabase.example.org 127 | port: 5433 128 | login_user: ourSuperuser 129 | login_password: thePassword 130 | state: absent 131 | ''' 132 | 133 | RETURN = r''' 134 | name: 135 | description: Name of the slot 136 | returned: always 137 | type: str 138 | sample: "physical_one" 139 | queries: 140 | description: List of executed queries. 141 | returned: always 142 | type: str 143 | sample: [ "SELECT pg_create_physical_replication_slot('physical_one', False, False)" ] 144 | ''' 145 | 146 | from ansible.module_utils.basic import AnsibleModule 147 | from ansible.module_utils.postgres import ( 148 | connect_to_db, 149 | exec_sql, 150 | get_conn_params, 151 | postgres_common_argument_spec, 152 | ) 153 | 154 | 155 | # =========================================== 156 | # PostgreSQL module specific support methods. 157 | # 158 | 159 | class PgSlot(object): 160 | def __init__(self, module, cursor, name): 161 | self.module = module 162 | self.cursor = cursor 163 | self.name = name 164 | self.exists = False 165 | self.kind = '' 166 | self.__slot_exists() 167 | self.changed = False 168 | self.executed_queries = [] 169 | version_tuple_str = [tple[1] for tple in cursor.connection.parameter_statuses if tple[0] == b'server_version'][ 170 | 0].decode('utf-8') 171 | version_list = (version_tuple_str.split('.') + [0])[0:3] 172 | self.server_version = int(''.join(["{:02}".format(int(x)) for x in version_list])) 173 | 174 | def create(self, kind='physical', immediately_reserve=False, output_plugin=False, just_check=False): 175 | if self.exists: 176 | if self.kind == kind: 177 | return False 178 | else: 179 | self.module.warn("slot with name '%s' already exists " 180 | "but has another type '%s'" % (self.name, self.kind)) 181 | return False 182 | 183 | if just_check: 184 | return None 185 | 186 | 187 | if kind == 'physical': 188 | # Check server version (needs for immedately_reserverd needs 9.6+): 189 | param_list = [self.name] 190 | if self.server_version < 96000: 191 | query = "SELECT pg_create_physical_replication_slot(%s)" 192 | else: 193 | query = "SELECT pg_create_physical_replication_slot(%s, %s)" 194 | param_list.append(immediately_reserve) 195 | 196 | self.changed = exec_sql(self, query, 197 | query_params=param_list, 198 | ddl=True) 199 | 200 | elif kind == 'logical': 201 | query = "SELECT pg_create_logical_replication_slot(%s, %s)" 202 | self.changed = exec_sql(self, query, 203 | query_params=[self.name, output_plugin], ddl=True) 204 | 205 | def drop(self): 206 | if not self.exists: 207 | return False 208 | 209 | query = "SELECT pg_drop_replication_slot(%s)" 210 | self.changed = exec_sql(self, query, query_params=[self.name], ddl=True) 211 | 212 | def __slot_exists(self): 213 | query = "SELECT slot_type FROM pg_replication_slots WHERE slot_name = %s" 214 | res = exec_sql(self, query, query_params=[self.name], add_to_executed=False) 215 | if res: 216 | self.exists = True 217 | self.kind = res[0][0] 218 | 219 | 220 | # =========================================== 221 | # Module execution. 222 | # 223 | 224 | 225 | def main(): 226 | argument_spec = postgres_common_argument_spec() 227 | argument_spec.update( 228 | db=dict(type="str", aliases=["login_db"]), 229 | name=dict(type="str", aliases=["slot_name"]), 230 | slot_type=dict(type="str", default="physical", choices=["logical", "physical"]), 231 | immediately_reserve=dict(type="bool", default=False), 232 | session_role=dict(type="str"), 233 | output_plugin=dict(type="str", default="test_decoding"), 234 | state=dict(type="str", default="present", choices=["absent", "present"]), 235 | ) 236 | 237 | module = AnsibleModule( 238 | argument_spec=argument_spec, 239 | supports_check_mode=True, 240 | ) 241 | 242 | name = module.params["name"] 243 | slot_type = module.params["slot_type"] 244 | immediately_reserve = module.params["immediately_reserve"] 245 | state = module.params["state"] 246 | output_plugin = module.params["output_plugin"] 247 | 248 | if immediately_reserve and slot_type == 'logical': 249 | module.fail_json(msg="Module parameters immediately_reserve and slot_type=logical are mutually exclusive") 250 | 251 | # When slot_type is logical and parameter db is not passed, 252 | # the default database will be used to create the slot and 253 | # the user should know about this. 254 | # When the slot type is physical, 255 | # it doesn't matter which database will be used 256 | # because physical slots are global objects. 257 | if slot_type == 'logical': 258 | warn_db_default = True 259 | else: 260 | warn_db_default = False 261 | 262 | conn_params = get_conn_params(module, module.params, warn_db_default=warn_db_default) 263 | db_connection = connect_to_db(module, conn_params, autocommit=True) 264 | cursor = db_connection.cursor() 265 | 266 | ################################## 267 | # Create an object and do main job 268 | pg_slot = PgSlot(module, cursor, name) 269 | 270 | changed = False 271 | 272 | if module.check_mode: 273 | if state == "present": 274 | if not pg_slot.exists: 275 | changed = True 276 | 277 | pg_slot.create(slot_type, immediately_reserve, output_plugin, just_check=True) 278 | 279 | elif state == "absent": 280 | if pg_slot.exists: 281 | changed = True 282 | else: 283 | if state == "absent": 284 | pg_slot.drop() 285 | 286 | elif state == "present": 287 | pg_slot.create(slot_type, immediately_reserve, output_plugin) 288 | 289 | changed = pg_slot.changed 290 | 291 | db_connection.close() 292 | module.exit_json(changed=changed, name=name, queries=pg_slot.executed_queries) 293 | 294 | 295 | if __name__ == '__main__': 296 | main() 297 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_query.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2017, Felix Archambault 5 | # Copyright: (c) 2019, Andrew Klychkov (@Andersson007) 6 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 7 | 8 | # Contribution: 9 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 10 | # Welcome to https://t.me/pro_ansible for discussion and support 11 | # License: please see above 12 | 13 | from __future__ import (absolute_import, division, print_function) 14 | __metaclass__ = type 15 | 16 | ANSIBLE_METADATA = { 17 | 'metadata_version': '1.1', 18 | 'supported_by': 'community', 19 | 'status': ['preview'] 20 | } 21 | 22 | DOCUMENTATION = r''' 23 | --- 24 | module: postgresql_query 25 | short_description: Run PostgreSQL queries 26 | description: 27 | - Runs arbitrary PostgreSQL queries. 28 | - Can run queries from SQL script files. 29 | - Does not run against backup files. Use M(postgresql_db) with I(state=restore) 30 | to run queries on files made by pg_dump/pg_dumpall utilities. 31 | version_added: '2.8' 32 | options: 33 | query: 34 | description: 35 | - SQL query to run. Variables can be escaped with psycopg2 syntax U(http://initd.org/psycopg/docs/usage.html). 36 | type: str 37 | positional_args: 38 | description: 39 | - List of values to be passed as positional arguments to the query. 40 | When the value is a list, it will be converted to PostgreSQL array. 41 | - Mutually exclusive with I(named_args). 42 | type: list 43 | named_args: 44 | description: 45 | - Dictionary of key-value arguments to pass to the query. 46 | When the value is a list, it will be converted to PostgreSQL array. 47 | - Mutually exclusive with I(positional_args). 48 | type: dict 49 | path_to_script: 50 | description: 51 | - Path to SQL script on the remote host. 52 | - Returns result of the last query in the script. 53 | - Mutually exclusive with I(query). 54 | type: path 55 | session_role: 56 | description: 57 | - Switch to session_role after connecting. The specified session_role must 58 | be a role that the current login_user is a member of. 59 | - Permissions checking for SQL commands is carried out as though 60 | the session_role were the one that had logged in originally. 61 | type: str 62 | db: 63 | description: 64 | - Name of database to connect to and run queries against. 65 | type: str 66 | aliases: 67 | - login_db 68 | autocommit: 69 | description: 70 | - Execute in autocommit mode when the query can't be run inside a transaction block 71 | (e.g., VACUUM). 72 | - Mutually exclusive with I(check_mode). 73 | type: bool 74 | default: no 75 | version_added: '2.9' 76 | seealso: 77 | - module: postgresql_db 78 | author: 79 | - Felix Archambault (@archf) 80 | - Andrew Klychkov (@Andersson007) 81 | - Will Rouesnel (@wrouesnel) 82 | extends_documentation_fragment: postgres 83 | ''' 84 | 85 | EXAMPLES = r''' 86 | - name: Simple select query to acme db 87 | postgresql_query: 88 | db: acme 89 | query: SELECT version() 90 | 91 | - name: Select query to db acme with positional arguments and non-default credentials 92 | postgresql_query: 93 | db: acme 94 | login_user: django 95 | login_password: mysecretpass 96 | query: SELECT * FROM acme WHERE id = %s AND story = %s 97 | positional_args: 98 | - 1 99 | - test 100 | 101 | - name: Select query to test_db with named_args 102 | postgresql_query: 103 | db: test_db 104 | query: SELECT * FROM test WHERE id = %(id_val)s AND story = %(story_val)s 105 | named_args: 106 | id_val: 1 107 | story_val: test 108 | 109 | - name: Insert query to test_table in db test_db 110 | postgresql_query: 111 | db: test_db 112 | query: INSERT INTO test_table (id, story) VALUES (2, 'my_long_story') 113 | 114 | - name: Run queries from SQL script 115 | postgresql_query: 116 | db: test_db 117 | path_to_script: /var/lib/pgsql/test.sql 118 | positional_args: 119 | - 1 120 | 121 | - name: Example of using autocommit parameter 122 | postgresql_query: 123 | db: test_db 124 | query: VACUUM 125 | autocommit: yes 126 | 127 | - name: > 128 | Insert data to the column of array type using positional_args. 129 | Note that we use quotes here, the same as for passing JSON, etc. 130 | postgresql_query: 131 | query: INSERT INTO test_table (array_column) VALUES (%s) 132 | positional_args: 133 | - '{1,2,3}' 134 | 135 | # Pass list and string vars as positional_args 136 | - name: Set vars 137 | set_fact: 138 | my_list: 139 | - 1 140 | - 2 141 | - 3 142 | my_arr: '{1, 2, 3}' 143 | 144 | - name: Select from test table by passing positional_args as arrays 145 | postgresql_query: 146 | query: SELECT * FROM test_array_table WHERE arr_col1 = %s AND arr_col2 = %s 147 | positional_args: 148 | - '{{ my_list }}' 149 | - '{{ my_arr|string }}' 150 | ''' 151 | 152 | RETURN = r''' 153 | query: 154 | description: Query that was tried to be executed. 155 | returned: always 156 | type: str 157 | sample: 'SELECT * FROM bar' 158 | statusmessage: 159 | description: Attribute containing the message returned by the command. 160 | returned: always 161 | type: str 162 | sample: 'INSERT 0 1' 163 | query_result: 164 | description: 165 | - List of dictionaries in column:value form representing returned rows. 166 | returned: changed 167 | type: list 168 | sample: [{"Column": "Value1"},{"Column": "Value2"}] 169 | rowcount: 170 | description: Number of affected rows. 171 | returned: changed 172 | type: int 173 | sample: 5 174 | ''' 175 | 176 | 177 | from ansible.module_utils.basic import AnsibleModule 178 | from ansible.module_utils.postgres import ( 179 | connect_to_db, 180 | get_conn_params, 181 | postgres_common_argument_spec, 182 | ) 183 | from ansible.module_utils._text import to_native 184 | from ansible.module_utils.six import iteritems 185 | 186 | # =========================================== 187 | # Module execution. 188 | # 189 | 190 | def list_to_pg_array(elem): 191 | """Convert the passed list to PostgreSQL array 192 | represented as a string. 193 | 194 | Args: 195 | elem (list): List that needs to be converted. 196 | 197 | Returns: 198 | elem (str): String representation of PostgreSQL array. 199 | """ 200 | elem = str(elem).strip('[]') 201 | elem = '{' + elem + '}' 202 | return elem 203 | 204 | 205 | def convert_elements_to_pg_arrays(obj): 206 | """Convert list elements of the passed object 207 | to PostgreSQL arrays represented as strings. 208 | 209 | Args: 210 | obj (dict or list): Object whose elements need to be converted. 211 | 212 | Returns: 213 | obj (dict or list): Object with converted elements. 214 | """ 215 | if isinstance(obj, dict): 216 | for (key, elem) in iteritems(obj): 217 | if isinstance(elem, list): 218 | obj[key] = list_to_pg_array(elem) 219 | 220 | elif isinstance(obj, list): 221 | for i, elem in enumerate(obj): 222 | if isinstance(elem, list): 223 | obj[i] = list_to_pg_array(elem) 224 | 225 | return obj 226 | 227 | 228 | def main(): 229 | argument_spec = postgres_common_argument_spec() 230 | argument_spec.update( 231 | query=dict(type='str'), 232 | db=dict(type='str', aliases=['login_db']), 233 | positional_args=dict(type='list'), 234 | named_args=dict(type='dict'), 235 | session_role=dict(type='str'), 236 | path_to_script=dict(type='path'), 237 | autocommit=dict(type='bool', default=False), 238 | ) 239 | 240 | module = AnsibleModule( 241 | argument_spec=argument_spec, 242 | mutually_exclusive=(('positional_args', 'named_args'),), 243 | supports_check_mode=True, 244 | ) 245 | 246 | query = module.params["query"] 247 | positional_args = module.params["positional_args"] 248 | named_args = module.params["named_args"] 249 | path_to_script = module.params["path_to_script"] 250 | autocommit = module.params["autocommit"] 251 | 252 | if autocommit and module.check_mode: 253 | module.fail_json(msg="Using autocommit is mutually exclusive with check_mode") 254 | 255 | if positional_args and named_args: 256 | module.fail_json(msg="positional_args and named_args params are mutually exclusive") 257 | 258 | if path_to_script and query: 259 | module.fail_json(msg="path_to_script is mutually exclusive with query") 260 | 261 | if positional_args: 262 | positional_args = convert_elements_to_pg_arrays(positional_args) 263 | 264 | elif named_args: 265 | named_args = convert_elements_to_pg_arrays(named_args) 266 | 267 | if path_to_script: 268 | try: 269 | query = open(path_to_script, 'r').read() 270 | except Exception as e: 271 | module.fail_json(msg="Cannot read file '%s' : %s" % (path_to_script, to_native(e))) 272 | 273 | conn_params = get_conn_params(module, module.params) 274 | db_connection = connect_to_db(module, conn_params, autocommit=autocommit) 275 | cursor = db_connection.cursor() 276 | 277 | # Prepare args: 278 | if module.params.get("positional_args"): 279 | arguments = module.params["positional_args"] 280 | elif module.params.get("named_args"): 281 | arguments = module.params["named_args"] 282 | else: 283 | arguments = [] 284 | 285 | # Set defaults: 286 | changed = False 287 | 288 | # Execute query: 289 | try: 290 | cursor.execute(query, arguments) 291 | except Exception as e: 292 | cursor.close() 293 | db_connection.close() 294 | module.fail_json(msg="Cannot execute SQL '%s' %s: %s" % (query, arguments, to_native(e))) 295 | statusmessage = cursor.connection.statusmessage 296 | rowcount = cursor.rowcount 297 | if rowcount != 0: 298 | try: 299 | query_result = [dict(row) for row in cursor.fetchall()] 300 | except db_connection.ProgrammingError as e: 301 | if to_native(e) == 'no results to fetch': 302 | query_result = {} 303 | elif to_native(e) == 'no result set': 304 | query_result = {} 305 | except Exception as e: 306 | module.fail_json(msg="Cannot fetch rows from cursor: %s" % to_native(e)) 307 | 308 | if 'SELECT' not in statusmessage: 309 | if 'UPDATE' in statusmessage or 'INSERT' in statusmessage or 'DELETE' in statusmessage: 310 | s = statusmessage.split() 311 | if len(s) == 3: 312 | if statusmessage.split()[2] != '0': 313 | changed = True 314 | 315 | elif len(s) == 2: 316 | if statusmessage.split()[1] != '0': 317 | changed = True 318 | 319 | else: 320 | changed = True 321 | 322 | else: 323 | changed = True 324 | changed = False 325 | 326 | if module.check_mode: 327 | db_connection.rollback() 328 | else: 329 | if not autocommit: 330 | db_connection.commit() 331 | 332 | kw = dict( 333 | changed=changed, 334 | query=query, 335 | statusmessage=statusmessage, 336 | query_result=query_result, 337 | rowcount=rowcount if rowcount >= 0 else 0, 338 | ) 339 | 340 | cursor.close() 341 | db_connection.close() 342 | 343 | module.exit_json(**kw) 344 | 345 | 346 | if __name__ == '__main__': 347 | main() 348 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_membership.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2019, Andrew Klychkov (@Andersson007) 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | 7 | # Contribution: 8 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 9 | # Welcome to https://t.me/pro_ansible for discussion and support 10 | # License: please see above 11 | 12 | from __future__ import (absolute_import, division, print_function) 13 | __metaclass__ = type 14 | 15 | ANSIBLE_METADATA = { 16 | 'metadata_version': '1.1', 17 | 'supported_by': 'community', 18 | 'status': ['preview'] 19 | } 20 | 21 | DOCUMENTATION = r''' 22 | --- 23 | module: postgresql_membership 24 | short_description: Add or remove PostgreSQL roles from groups 25 | description: 26 | - Adds or removes PostgreSQL roles from groups (other roles). 27 | - Users are roles with login privilege. 28 | - Groups are PostgreSQL roles usually without LOGIN privilege. 29 | - "Common use case:" 30 | - 1) add a new group (groups) by M(postgresql_user) module with I(role_attr_flags=NOLOGIN) 31 | - 2) grant them desired privileges by M(postgresql_privs) module 32 | - 3) add desired PostgreSQL users to the new group (groups) by this module 33 | version_added: '2.8' 34 | options: 35 | groups: 36 | description: 37 | - The list of groups (roles) that need to be granted to or revoked from I(target_roles). 38 | required: yes 39 | type: list 40 | elements: str 41 | aliases: 42 | - group 43 | - source_role 44 | - source_roles 45 | target_roles: 46 | description: 47 | - The list of target roles (groups will be granted to them). 48 | required: yes 49 | type: list 50 | elements: str 51 | aliases: 52 | - target_role 53 | - users 54 | - user 55 | fail_on_role: 56 | description: 57 | - If C(yes), fail when group or target_role doesn't exist. If C(no), just warn and continue. 58 | default: yes 59 | type: bool 60 | state: 61 | description: 62 | - Membership state. 63 | - I(state=present) implies the I(groups)must be granted to I(target_roles). 64 | - I(state=absent) implies the I(groups) must be revoked from I(target_roles). 65 | type: str 66 | default: present 67 | choices: [ absent, present ] 68 | db: 69 | description: 70 | - Name of database to connect to. 71 | type: str 72 | aliases: 73 | - login_db 74 | session_role: 75 | description: 76 | - Switch to session_role after connecting. 77 | The specified session_role must be a role that the current login_user is a member of. 78 | - Permissions checking for SQL commands is carried out as though 79 | the session_role were the one that had logged in originally. 80 | type: str 81 | seealso: 82 | - module: postgresql_user 83 | - module: postgresql_privs 84 | - module: postgresql_owner 85 | - name: PostgreSQL role membership reference 86 | description: Complete reference of the PostgreSQL role membership documentation. 87 | link: https://www.postgresql.org/docs/current/role-membership.html 88 | - name: PostgreSQL role attributes reference 89 | description: Complete reference of the PostgreSQL role attributes documentation. 90 | link: https://www.postgresql.org/docs/current/role-attributes.html 91 | author: 92 | - Andrew Klychkov (@Andersson007) 93 | extends_documentation_fragment: postgres 94 | ''' 95 | 96 | EXAMPLES = r''' 97 | - name: Grant role read_only to alice and bob 98 | postgresql_membership: 99 | group: read_only 100 | target_roles: 101 | - alice 102 | - bob 103 | state: present 104 | 105 | # you can also use target_roles: alice,bob,etc to pass the role list 106 | 107 | - name: Revoke role read_only and exec_func from bob. Ignore if roles don't exist 108 | postgresql_membership: 109 | groups: 110 | - read_only 111 | - exec_func 112 | target_role: bob 113 | fail_on_role: no 114 | state: absent 115 | ''' 116 | 117 | RETURN = r''' 118 | queries: 119 | description: List of executed queries. 120 | returned: always 121 | type: str 122 | sample: [ "GRANT \"user_ro\" TO \"alice\"" ] 123 | granted: 124 | description: Dict of granted groups and roles. 125 | returned: if I(state=present) 126 | type: dict 127 | sample: { "ro_group": [ "alice", "bob" ] } 128 | revoked: 129 | description: Dict of revoked groups and roles. 130 | returned: if I(state=absent) 131 | type: dict 132 | sample: { "ro_group": [ "alice", "bob" ] } 133 | state: 134 | description: Membership state that tried to be set. 135 | returned: always 136 | type: str 137 | sample: "present" 138 | ''' 139 | 140 | from ansible.module_utils.basic import AnsibleModule 141 | from ansible.module_utils.database import pg_quote_identifier 142 | from ansible.module_utils.postgres import ( 143 | connect_to_db, 144 | exec_sql, 145 | get_conn_params, 146 | postgres_common_argument_spec, 147 | ) 148 | 149 | 150 | class PgMembership(object): 151 | def __init__(self, module, cursor, groups, target_roles, fail_on_role): 152 | self.module = module 153 | self.cursor = cursor 154 | self.target_roles = [r.strip() for r in target_roles] 155 | self.groups = [r.strip() for r in groups] 156 | self.executed_queries = [] 157 | self.granted = {} 158 | self.revoked = {} 159 | self.fail_on_role = fail_on_role 160 | self.non_existent_roles = [] 161 | self.changed = False 162 | self.__check_roles_exist() 163 | 164 | def grant(self): 165 | for group in self.groups: 166 | self.granted[group] = [] 167 | 168 | for role in self.target_roles: 169 | # If role is in a group now, pass: 170 | if self.__check_membership(group, role): 171 | continue 172 | 173 | query = "GRANT %s TO %s" % ((pg_quote_identifier(group, 'role'), 174 | (pg_quote_identifier(role, 'role')))) 175 | self.changed = exec_sql(self, query, ddl=True) 176 | 177 | if self.changed: 178 | self.granted[group].append(role) 179 | 180 | return self.changed 181 | 182 | def revoke(self): 183 | for group in self.groups: 184 | self.revoked[group] = [] 185 | 186 | for role in self.target_roles: 187 | # If role is not in a group now, pass: 188 | if not self.__check_membership(group, role): 189 | continue 190 | 191 | query = "REVOKE %s FROM %s" % ((pg_quote_identifier(group, 'role'), 192 | (pg_quote_identifier(role, 'role')))) 193 | self.changed = exec_sql(self, query, ddl=True) 194 | 195 | if self.changed: 196 | self.revoked[group].append(role) 197 | 198 | return self.changed 199 | 200 | def __check_membership(self, src_role, dst_role): 201 | query = ("SELECT ARRAY(SELECT b.rolname FROM " 202 | "pg_catalog.pg_auth_members m " 203 | "JOIN pg_catalog.pg_roles b ON (m.roleid = b.oid) " 204 | "WHERE m.member = r.oid) " 205 | "FROM pg_catalog.pg_roles r " 206 | "WHERE r.rolname = '%s'" % dst_role) 207 | 208 | res = exec_sql(self, query, add_to_executed=False) 209 | membership = [] 210 | if res: 211 | membership = res[0][0] 212 | 213 | if not membership: 214 | return False 215 | 216 | if src_role in membership: 217 | return True 218 | 219 | return False 220 | 221 | def __check_roles_exist(self): 222 | for group in self.groups: 223 | if not self.__role_exists(group): 224 | if self.fail_on_role: 225 | self.module.fail_json(msg="Role %s does not exist" % group) 226 | else: 227 | self.module.warn("Role %s does not exist, pass" % group) 228 | self.non_existent_roles.append(group) 229 | 230 | for role in self.target_roles: 231 | if not self.__role_exists(role): 232 | if self.fail_on_role: 233 | self.module.fail_json(msg="Role %s does not exist" % role) 234 | else: 235 | self.module.warn("Role %s does not exist, pass" % role) 236 | 237 | if role not in self.groups: 238 | self.non_existent_roles.append(role) 239 | 240 | else: 241 | if self.fail_on_role: 242 | self.module.exit_json(msg="Role role '%s' is a member of role '%s'" % (role, role)) 243 | else: 244 | self.module.warn("Role role '%s' is a member of role '%s', pass" % (role, role)) 245 | 246 | # Update role lists, excluding non existent roles: 247 | self.groups = [g for g in self.groups if g not in self.non_existent_roles] 248 | 249 | self.target_roles = [r for r in self.target_roles if r not in self.non_existent_roles] 250 | 251 | def __role_exists(self, role): 252 | return exec_sql(self, "SELECT 1 FROM pg_roles WHERE rolname = '%s'" % role, add_to_executed=False) 253 | 254 | 255 | # =========================================== 256 | # Module execution. 257 | # 258 | 259 | 260 | def main(): 261 | argument_spec = postgres_common_argument_spec() 262 | argument_spec.update( 263 | groups=dict(type='list', aliases=['group', 'source_role', 'source_roles']), 264 | target_roles=dict(type='list', aliases=['target_role', 'user', 'users']), 265 | fail_on_role=dict(type='bool', default=True), 266 | state=dict(type='str', default='present', choices=['absent', 'present']), 267 | db=dict(type='str', aliases=['login_db']), 268 | session_role=dict(type='str'), 269 | ) 270 | 271 | module = AnsibleModule( 272 | argument_spec=argument_spec, 273 | supports_check_mode=True, 274 | ) 275 | 276 | groups = module.params['groups'] 277 | target_roles = module.params['target_roles'] 278 | fail_on_role = module.params['fail_on_role'] 279 | state = module.params['state'] 280 | 281 | conn_params = get_conn_params(module, module.params, warn_db_default=False) 282 | db_connection = connect_to_db(module, conn_params, autocommit=False) 283 | cursor = db_connection.cursor() 284 | 285 | ############## 286 | # Create the object and do main job: 287 | 288 | pg_membership = PgMembership(module, cursor, groups, target_roles, fail_on_role) 289 | 290 | if state == 'present': 291 | pg_membership.grant() 292 | 293 | elif state == 'absent': 294 | pg_membership.revoke() 295 | 296 | # Rollback if it's possible and check_mode: 297 | if module.check_mode: 298 | db_connection.rollback() 299 | else: 300 | db_connection.commit() 301 | 302 | cursor.close() 303 | db_connection.close() 304 | 305 | # Make return values: 306 | return_dict = dict( 307 | changed=pg_membership.changed, 308 | state=state, 309 | groups=pg_membership.groups, 310 | target_roles=pg_membership.target_roles, 311 | queries=pg_membership.executed_queries, 312 | ) 313 | 314 | if state == 'present': 315 | return_dict['granted'] = pg_membership.granted 316 | elif state == 'absent': 317 | return_dict['revoked'] = pg_membership.revoked 318 | 319 | module.exit_json(**return_dict) 320 | 321 | 322 | if __name__ == '__main__': 323 | main() 324 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_lang.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # (c) 2014, Jens Depuydt 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | 7 | # Contribution: 8 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 9 | # Welcome to https://t.me/pro_ansible for discussion and support 10 | # License: please see above 11 | 12 | from __future__ import absolute_import, division, print_function 13 | __metaclass__ = type 14 | 15 | 16 | ANSIBLE_METADATA = {'metadata_version': '1.1', 17 | 'status': ['preview'], 18 | 'supported_by': 'community'} 19 | 20 | DOCUMENTATION = r''' 21 | --- 22 | module: postgresql_lang 23 | short_description: Adds, removes or changes procedural languages with a PostgreSQL database 24 | description: 25 | - Adds, removes or changes procedural languages with a PostgreSQL database. 26 | - This module allows you to add a language, remote a language or change the trust 27 | relationship with a PostgreSQL database. 28 | - The module can be used on the machine where executed or on a remote host. 29 | - When removing a language from a database, it is possible that dependencies prevent 30 | the database from being removed. In that case, you can specify I(cascade=yes) to 31 | automatically drop objects that depend on the language (such as functions in the 32 | language). 33 | - In case the language can't be deleted because it is required by the 34 | database system, you can specify I(fail_on_drop=no) to ignore the error. 35 | - Be careful when marking a language as trusted since this could be a potential 36 | security breach. Untrusted languages allow only users with the PostgreSQL superuser 37 | privilege to use this language to create new functions. 38 | version_added: '1.7' 39 | options: 40 | lang: 41 | description: 42 | - Name of the procedural language to add, remove or change. 43 | required: true 44 | type: str 45 | aliases: 46 | - name 47 | trust: 48 | description: 49 | - Make this language trusted for the selected db. 50 | type: bool 51 | default: 'no' 52 | db: 53 | description: 54 | - Name of database to connect to and where the language will be added, removed or changed. 55 | type: str 56 | aliases: 57 | - login_db 58 | force_trust: 59 | description: 60 | - Marks the language as trusted, even if it's marked as untrusted in pg_pltemplate. 61 | - Use with care! 62 | type: bool 63 | default: 'no' 64 | fail_on_drop: 65 | description: 66 | - If C(yes), fail when removing a language. Otherwise just log and continue. 67 | - In some cases, it is not possible to remove a language (used by the db-system). 68 | - When dependencies block the removal, consider using I(cascade). 69 | type: bool 70 | default: 'yes' 71 | cascade: 72 | description: 73 | - When dropping a language, also delete object that depend on this language. 74 | - Only used when I(state=absent). 75 | type: bool 76 | default: 'no' 77 | session_role: 78 | version_added: '2.8' 79 | description: 80 | - Switch to session_role after connecting. 81 | - The specified I(session_role) must be a role that the current I(login_user) is a member of. 82 | - Permissions checking for SQL commands is carried out as though the I(session_role) were the one that had logged in originally. 83 | type: str 84 | state: 85 | description: 86 | - The state of the language for the selected database. 87 | type: str 88 | default: present 89 | choices: [ absent, present ] 90 | login_unix_socket: 91 | description: 92 | - Path to a Unix domain socket for local connections. 93 | type: str 94 | version_added: '2.8' 95 | ssl_mode: 96 | description: 97 | - Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. 98 | - See U(https://www.postgresql.org/docs/current/static/libpq-ssl.html) for more information on the modes. 99 | - Default of C(prefer) matches libpq default. 100 | type: str 101 | default: prefer 102 | choices: [ allow, disable, prefer, require, verify-ca, verify-full ] 103 | version_added: '2.8' 104 | ca_cert: 105 | description: 106 | - Specifies the name of a file containing SSL certificate authority (CA) certificate(s). 107 | - If the file exists, the server's certificate will be verified to be signed by one of these authorities. 108 | type: str 109 | aliases: [ ssl_rootcert ] 110 | version_added: '2.8' 111 | seealso: 112 | - name: PostgreSQL languages 113 | description: General information about PostgreSQL languages. 114 | link: https://www.postgresql.org/docs/current/xplang.html 115 | - name: CREATE LANGUAGE reference 116 | description: Complete reference of the CREATE LANGUAGE command documentation. 117 | link: https://www.postgresql.org/docs/current/sql-createlanguage.html 118 | - name: ALTER LANGUAGE reference 119 | description: Complete reference of the ALTER LANGUAGE command documentation. 120 | link: https://www.postgresql.org/docs/current/sql-alterlanguage.html 121 | - name: DROP LANGUAGE reference 122 | description: Complete reference of the DROP LANGUAGE command documentation. 123 | link: https://www.postgresql.org/docs/current/sql-droplanguage.html 124 | author: 125 | - Jens Depuydt (@jensdepuydt) 126 | - Thomas O'Donnell (@andytom) 127 | extends_documentation_fragment: postgres 128 | ''' 129 | 130 | EXAMPLES = r''' 131 | - name: Add language pltclu to database testdb if it doesn't exist 132 | postgresql_lang: db=testdb lang=pltclu state=present 133 | 134 | # Add language pltclu to database testdb if it doesn't exist and mark it as trusted. 135 | # Marks the language as trusted if it exists but isn't trusted yet. 136 | # force_trust makes sure that the language will be marked as trusted 137 | - name: Add language pltclu to database testdb if it doesn't exist and mark it as trusted 138 | postgresql_lang: 139 | db: testdb 140 | lang: pltclu 141 | state: present 142 | trust: yes 143 | force_trust: yes 144 | 145 | - name: Remove language pltclu from database testdb 146 | postgresql_lang: 147 | db: testdb 148 | lang: pltclu 149 | state: absent 150 | 151 | - name: Remove language pltclu from database testdb and remove all dependencies 152 | postgresql_lang: 153 | db: testdb 154 | lang: pltclu 155 | state: absent 156 | cascade: yes 157 | 158 | - name: Remove language c from database testdb but ignore errors if something prevents the removal 159 | postgresql_lang: 160 | db: testdb 161 | lang: pltclu 162 | state: absent 163 | fail_on_drop: no 164 | ''' 165 | 166 | RETURN = r''' 167 | queries: 168 | description: List of executed queries. 169 | returned: always 170 | type: list 171 | sample: ['CREATE LANGUAGE "acme"'] 172 | version_added: '2.8' 173 | ''' 174 | 175 | from ansible.module_utils.basic import AnsibleModule 176 | from ansible.module_utils.postgres import ( 177 | connect_to_db, 178 | get_conn_params, 179 | postgres_common_argument_spec, 180 | ) 181 | 182 | executed_queries = [] 183 | 184 | 185 | def lang_exists(cursor, lang): 186 | """Checks if language exists for db""" 187 | query = "SELECT lanname FROM pg_language WHERE lanname = %s" 188 | cursor.execute(query, [lang]) 189 | return cursor.rowcount > 0 190 | 191 | 192 | def lang_istrusted(cursor, lang): 193 | """Checks if language is trusted for db""" 194 | query = "SELECT lanpltrusted FROM pg_language WHERE lanname = %s" 195 | cursor.execute(query, [lang]) 196 | return cursor.fetchone()[0] 197 | 198 | 199 | def lang_altertrust(cursor, lang, trust): 200 | """Changes if language is trusted for db""" 201 | query = "UPDATE pg_language SET lanpltrusted = %s WHERE lanname = %s" 202 | cursor.execute(query, (trust, lang)) 203 | executed_queries.append(query % (trust, lang)) 204 | return True 205 | 206 | 207 | def lang_add(cursor, lang, trust): 208 | """Adds language for db""" 209 | if trust: 210 | query = 'CREATE TRUSTED LANGUAGE "%s"' % lang 211 | else: 212 | query = 'CREATE LANGUAGE "%s"' % lang 213 | executed_queries.append(query) 214 | cursor.execute(query) 215 | return True 216 | 217 | 218 | def lang_drop(cursor, lang, cascade): 219 | """Drops language for db""" 220 | cursor.execute("SAVEPOINT ansible_pgsql_lang_drop") 221 | try: 222 | if cascade: 223 | query = "DROP LANGUAGE \"%s\" CASCADE" % lang 224 | else: 225 | query = "DROP LANGUAGE \"%s\"" % lang 226 | executed_queries.append(query) 227 | cursor.execute(query) 228 | except Exception: 229 | cursor.execute("ROLLBACK TO SAVEPOINT ansible_pgsql_lang_drop") 230 | cursor.execute("RELEASE SAVEPOINT ansible_pgsql_lang_drop") 231 | return False 232 | cursor.execute("RELEASE SAVEPOINT ansible_pgsql_lang_drop") 233 | return True 234 | 235 | 236 | def main(): 237 | argument_spec = postgres_common_argument_spec() 238 | argument_spec.update( 239 | db=dict(type="str", required=True, aliases=["login_db"]), 240 | lang=dict(type="str", required=True, aliases=["name"]), 241 | state=dict(type="str", default="present", choices=["absent", "present"]), 242 | trust=dict(type="bool", default="no"), 243 | force_trust=dict(type="bool", default="no"), 244 | cascade=dict(type="bool", default="no"), 245 | fail_on_drop=dict(type="bool", default="yes"), 246 | session_role=dict(type="str"), 247 | ) 248 | 249 | module = AnsibleModule( 250 | argument_spec=argument_spec, 251 | supports_check_mode=True, 252 | ) 253 | 254 | db = module.params["db"] 255 | lang = module.params["lang"] 256 | state = module.params["state"] 257 | trust = module.params["trust"] 258 | force_trust = module.params["force_trust"] 259 | cascade = module.params["cascade"] 260 | fail_on_drop = module.params["fail_on_drop"] 261 | 262 | conn_params = get_conn_params(module, module.params) 263 | db_connection = connect_to_db(module, conn_params, autocommit=False) 264 | cursor = db_connection.cursor() 265 | 266 | changed = False 267 | kw = {'db': db, 'lang': lang, 'trust': trust} 268 | 269 | if state == "present": 270 | if lang_exists(cursor, lang): 271 | lang_trusted = lang_istrusted(cursor, lang) 272 | if (lang_trusted and not trust) or (not lang_trusted and trust): 273 | if module.check_mode: 274 | changed = True 275 | else: 276 | changed = lang_altertrust(cursor, lang, trust) 277 | else: 278 | if module.check_mode: 279 | changed = True 280 | else: 281 | changed = lang_add(cursor, lang, trust) 282 | if force_trust: 283 | changed = lang_altertrust(cursor, lang, trust) 284 | 285 | else: 286 | if lang_exists(cursor, lang): 287 | if module.check_mode: 288 | changed = True 289 | kw['lang_dropped'] = True 290 | else: 291 | changed = lang_drop(cursor, lang, cascade) 292 | if fail_on_drop and not changed: 293 | msg = "unable to drop language, use cascade to delete dependencies or fail_on_drop=no to ignore" 294 | module.fail_json(msg=msg) 295 | kw['lang_dropped'] = changed 296 | 297 | if changed: 298 | if module.check_mode: 299 | db_connection.rollback() 300 | else: 301 | db_connection.commit() 302 | 303 | kw['changed'] = changed 304 | kw['queries'] = executed_queries 305 | db_connection.close() 306 | module.exit_json(**kw) 307 | 308 | 309 | if __name__ == '__main__': 310 | main() 311 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_copy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2019, Andrew Klychkov (@Andersson007) 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | 7 | # Contribution: 8 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 9 | # Welcome to https://t.me/pro_ansible for discussion and support 10 | # License: please see above 11 | 12 | from __future__ import (absolute_import, division, print_function) 13 | __metaclass__ = type 14 | 15 | ANSIBLE_METADATA = { 16 | 'metadata_version': '1.1', 17 | 'supported_by': 'community', 18 | 'status': ['preview'] 19 | } 20 | 21 | DOCUMENTATION = r''' 22 | --- 23 | module: postgresql_copy 24 | short_description: Copy data between a file/program and a PostgreSQL table 25 | description: 26 | - Copy data between a file/program and a PostgreSQL table. 27 | version_added: '2.9' 28 | 29 | options: 30 | copy_to: 31 | description: 32 | - Copy the contents of a table to a file. 33 | - Can also copy the results of a SELECT query. 34 | - Mutually exclusive with I(copy_from) and I(dst). 35 | type: path 36 | aliases: [ to ] 37 | copy_from: 38 | description: 39 | - Copy data from a file to a table (appending the data to whatever is in the table already). 40 | - Mutually exclusive with I(copy_to) and I(src). 41 | type: path 42 | aliases: [ from ] 43 | src: 44 | description: 45 | - Copy data from I(copy_from) to I(src=tablename). 46 | - Used with I(copy_to) only. 47 | type: str 48 | aliases: [ source ] 49 | dst: 50 | description: 51 | - Copy data to I(dst=tablename) from I(copy_from=/path/to/data.file). 52 | - Used with I(copy_from) only. 53 | type: str 54 | aliases: [ destination ] 55 | columns: 56 | description: 57 | - List of column names for the src/dst table to COPY FROM/TO. 58 | type: list 59 | elements: str 60 | aliases: [ column ] 61 | program: 62 | description: 63 | - Mark I(src)/I(dst) as a program. Data will be copied to/from a program. 64 | - See block Examples and PROGRAM arg description U(https://www.postgresql.org/docs/current/sql-copy.html). 65 | type: bool 66 | default: no 67 | options: 68 | description: 69 | - Options of COPY command. 70 | - See the full list of available options U(https://www.postgresql.org/docs/current/sql-copy.html). 71 | type: dict 72 | db: 73 | description: 74 | - Name of database to connect to. 75 | type: str 76 | aliases: [ login_db ] 77 | session_role: 78 | description: 79 | - Switch to session_role after connecting. 80 | The specified session_role must be a role that the current login_user is a member of. 81 | - Permissions checking for SQL commands is carried out as though 82 | the session_role were the one that had logged in originally. 83 | type: str 84 | 85 | notes: 86 | - Supports PostgreSQL version 9.4+. 87 | - COPY command is only allowed to database superusers. 88 | - if I(check_mode=yes), we just check the src/dst table availability 89 | and return the COPY query that actually has not been executed. 90 | - If i(check_mode=yes) and the source has been passed as SQL, the module 91 | will execute it and rolled the transaction back but pay attention 92 | it can affect database performance (e.g., if SQL collects a lot of data). 93 | 94 | seealso: 95 | - name: COPY command reference 96 | description: Complete reference of the COPY command documentation. 97 | link: https://www.postgresql.org/docs/current/sql-copy.html 98 | 99 | author: 100 | - Andrew Klychkov (@Andersson007) 101 | 102 | extends_documentation_fragment: postgres 103 | ''' 104 | 105 | EXAMPLES = r''' 106 | - name: Copy text TAB-separated data from file /tmp/data.txt to acme table 107 | postgresql_copy: 108 | copy_from: /tmp/data.txt 109 | dst: acme 110 | 111 | - name: Copy CSV (comma-separated) data from file /tmp/data.csv to columns id, name of table acme 112 | postgresql_copy: 113 | copy_from: /tmp/data.csv 114 | dst: acme 115 | columns: id,name 116 | options: 117 | format: csv 118 | 119 | - name: > 120 | Copy text vertical-bar-separated data from file /tmp/data.txt to bar table. 121 | The NULL values are specified as N 122 | postgresql_copy: 123 | copy_from: /tmp/data.csv 124 | dst: bar 125 | options: 126 | delimiter: '|' 127 | null: 'N' 128 | 129 | - name: Copy data from acme table to file /tmp/data.txt in text format, TAB-separated 130 | postgresql_copy: 131 | src: acme 132 | copy_to: /tmp/data.txt 133 | 134 | - name: Copy data from SELECT query to/tmp/data.csv in CSV format 135 | postgresql_copy: 136 | src: 'SELECT * FROM acme' 137 | copy_to: /tmp/data.csv 138 | options: 139 | format: csv 140 | 141 | - name: Copy CSV data from my_table to gzip 142 | postgresql_copy: 143 | src: my_table 144 | copy_to: 'gzip > /tmp/data.csv.gz' 145 | program: yes 146 | options: 147 | format: csv 148 | 149 | - name: > 150 | Copy data from columns id, name of table bar to /tmp/data.txt. 151 | Output format is text, vertical-bar-separated, NULL as N 152 | postgresql_copy: 153 | src: bar 154 | columns: 155 | - id 156 | - name 157 | copy_to: /tmp/data.csv 158 | options: 159 | delimiter: '|' 160 | null: 'N' 161 | ''' 162 | 163 | RETURN = r''' 164 | queries: 165 | description: List of executed queries. 166 | returned: always 167 | type: str 168 | sample: [ "COPY test_table FROM '/tmp/data_file.txt' (FORMAT csv, DELIMITER ',', NULL 'NULL')" ] 169 | src: 170 | description: Data source. 171 | returned: always 172 | type: str 173 | sample: "mytable" 174 | dst: 175 | description: Data destination. 176 | returned: always 177 | type: str 178 | sample: "/tmp/data.csv" 179 | ''' 180 | 181 | 182 | from ansible.module_utils.basic import AnsibleModule 183 | from ansible.module_utils.database import pg_quote_identifier 184 | from ansible.module_utils.postgres import ( 185 | connect_to_db, 186 | exec_sql, 187 | get_conn_params, 188 | postgres_common_argument_spec, 189 | ) 190 | from ansible.module_utils.six import iteritems 191 | 192 | 193 | class PgCopyData(object): 194 | 195 | """Implements behavior of COPY FROM, COPY TO PostgreSQL command. 196 | 197 | Arguments: 198 | module (AnsibleModule) -- object of AnsibleModule class 199 | cursor (cursor) -- cursor object of psycopg2 library 200 | 201 | Attributes: 202 | module (AnsibleModule) -- object of AnsibleModule class 203 | cursor (cursor) -- cursor object of psycopg2 library 204 | changed (bool) -- something was changed after execution or not 205 | executed_queries (list) -- executed queries 206 | dst (str) -- data destination table (when copy_from) 207 | src (str) -- data source table (when copy_to) 208 | opt_need_quotes (tuple) -- values of these options must be passed 209 | to SQL in quotes 210 | """ 211 | 212 | def __init__(self, module, cursor): 213 | self.module = module 214 | self.cursor = cursor 215 | self.executed_queries = [] 216 | self.changed = False 217 | self.dst = '' 218 | self.src = '' 219 | self.opt_need_quotes = ( 220 | 'DELIMITER', 221 | 'NULL', 222 | 'QUOTE', 223 | 'ESCAPE', 224 | 'ENCODING', 225 | ) 226 | 227 | def copy_from(self): 228 | """Implements COPY FROM command behavior.""" 229 | self.src = self.module.params['copy_from'] 230 | self.dst = self.module.params['dst'] 231 | 232 | query_fragments = ['COPY %s' % pg_quote_identifier(self.dst, 'table')] 233 | 234 | if self.module.params.get('columns'): 235 | query_fragments.append('(%s)' % ','.join(self.module.params['columns'])) 236 | 237 | query_fragments.append('FROM') 238 | 239 | if self.module.params.get('program'): 240 | query_fragments.append('PROGRAM') 241 | 242 | query_fragments.append("'%s'" % self.src) 243 | 244 | if self.module.params.get('options'): 245 | query_fragments.append(self.__transform_options()) 246 | 247 | # Note: check mode is implemented here: 248 | if self.module.check_mode: 249 | self.changed = self.__check_table(self.dst) 250 | 251 | if self.changed: 252 | self.executed_queries.append(' '.join(query_fragments)) 253 | else: 254 | if exec_sql(self, ' '.join(query_fragments), ddl=True): 255 | self.changed = True 256 | 257 | def copy_to(self): 258 | """Implements COPY TO command behavior.""" 259 | self.src = self.module.params['src'] 260 | self.dst = self.module.params['copy_to'] 261 | 262 | if 'SELECT ' in self.src.upper(): 263 | # If src is SQL SELECT statement: 264 | query_fragments = ['COPY (%s)' % self.src] 265 | else: 266 | # If src is a table: 267 | query_fragments = ['COPY %s' % pg_quote_identifier(self.src, 'table')] 268 | 269 | if self.module.params.get('columns'): 270 | query_fragments.append('(%s)' % ','.join(self.module.params['columns'])) 271 | 272 | query_fragments.append('TO') 273 | 274 | if self.module.params.get('program'): 275 | query_fragments.append('PROGRAM') 276 | 277 | query_fragments.append("'%s'" % self.dst) 278 | 279 | if self.module.params.get('options'): 280 | query_fragments.append(self.__transform_options()) 281 | 282 | # Note: check mode is implemented here: 283 | if self.module.check_mode: 284 | self.changed = self.__check_table(self.src) 285 | 286 | if self.changed: 287 | self.executed_queries.append(' '.join(query_fragments)) 288 | else: 289 | if exec_sql(self, ' '.join(query_fragments), ddl=True): 290 | self.changed = True 291 | 292 | def __transform_options(self): 293 | """Transform options dict into a suitable string.""" 294 | for (key, val) in iteritems(self.module.params['options']): 295 | if key.upper() in self.opt_need_quotes: 296 | self.module.params['options'][key] = "'%s'" % val 297 | 298 | opt = ['%s %s' % (key, val) for (key, val) in iteritems(self.module.params['options'])] 299 | return '(%s)' % ', '.join(opt) 300 | 301 | def __check_table(self, table): 302 | """Check table or SQL in transaction mode for check_mode. 303 | 304 | Return True if it is OK. 305 | 306 | Arguments: 307 | table (str) - Table name that needs to be checked. 308 | It can be SQL SELECT statement that was passed 309 | instead of the table name. 310 | """ 311 | if 'SELECT ' in table.upper(): 312 | # In this case table is actually SQL SELECT statement. 313 | # If SQL fails, it's handled by exec_sql(): 314 | exec_sql(self, table, add_to_executed=False) 315 | # If exec_sql was passed, it means all is OK: 316 | return True 317 | 318 | exec_sql(self, 'SELECT 1 FROM %s' % pg_quote_identifier(table, 'table'), 319 | add_to_executed=False) 320 | # If SQL was executed successfully: 321 | return True 322 | 323 | 324 | # =========================================== 325 | # Module execution. 326 | # 327 | 328 | 329 | def main(): 330 | argument_spec = postgres_common_argument_spec() 331 | argument_spec.update( 332 | copy_to=dict(type='path', aliases=['to']), 333 | copy_from=dict(type='path', aliases=['from']), 334 | src=dict(type='str', aliases=['source']), 335 | dst=dict(type='str', aliases=['destination']), 336 | columns=dict(type='list', aliases=['column']), 337 | options=dict(type='dict'), 338 | program=dict(type='bool', default=False), 339 | db=dict(type='str', aliases=['login_db']), 340 | session_role=dict(type='str'), 341 | ) 342 | module = AnsibleModule( 343 | argument_spec=argument_spec, 344 | supports_check_mode=True, 345 | mutually_exclusive=[ 346 | ['copy_from', 'copy_to'], 347 | ['copy_from', 'src'], 348 | ['copy_to', 'dst'], 349 | ] 350 | ) 351 | 352 | # Note: we don't need to check mutually exclusive params here, because they are 353 | # checked automatically by AnsibleModule (mutually_exclusive=[] list above). 354 | if module.params.get('copy_from') and not module.params.get('dst'): 355 | module.fail_json(msg='dst param is necessary with copy_from') 356 | 357 | elif module.params.get('copy_to') and not module.params.get('src'): 358 | module.fail_json(msg='src param is necessary with copy_to') 359 | 360 | # Connect to DB and make cursor object: 361 | conn_params = get_conn_params(module, module.params) 362 | db_connection = connect_to_db(module, conn_params, autocommit=False) 363 | cursor = db_connection.cursor() 364 | 365 | ############## 366 | # Create the object and do main job: 367 | data = PgCopyData(module, cursor) 368 | 369 | # Note: parameters like dst, src, etc. are got 370 | # from module object into data object of PgCopyData class. 371 | # Therefore not need to pass args to the methods below. 372 | # Note: check mode is implemented inside the methods below 373 | # by checking passed module.check_mode arg. 374 | if module.params.get('copy_to'): 375 | data.copy_to() 376 | 377 | elif module.params.get('copy_from'): 378 | data.copy_from() 379 | 380 | # Finish: 381 | if module.check_mode: 382 | db_connection.rollback() 383 | else: 384 | db_connection.commit() 385 | 386 | cursor.close() 387 | db_connection.close() 388 | 389 | # Return some values: 390 | module.exit_json( 391 | changed=data.changed, 392 | queries=data.executed_queries, 393 | src=data.src, 394 | dst=data.dst, 395 | ) 396 | 397 | 398 | if __name__ == '__main__': 399 | main() 400 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_ext.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: Ansible Project 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | 7 | # Contribution: 8 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 9 | # Welcome to https://t.me/pro_ansible for discussion and support 10 | # License: please see above 11 | 12 | from __future__ import absolute_import, division, print_function 13 | __metaclass__ = type 14 | 15 | ANSIBLE_METADATA = {'metadata_version': '1.1', 16 | 'status': ['preview'], 17 | 'supported_by': 'community'} 18 | 19 | DOCUMENTATION = r''' 20 | --- 21 | module: postgresql_ext 22 | short_description: Add or remove PostgreSQL extensions from a database 23 | description: 24 | - Add or remove PostgreSQL extensions from a database. 25 | version_added: '1.9' 26 | options: 27 | name: 28 | description: 29 | - Name of the extension to add or remove. 30 | required: true 31 | type: str 32 | aliases: 33 | - ext 34 | db: 35 | description: 36 | - Name of the database to add or remove the extension to/from. 37 | required: true 38 | type: str 39 | aliases: 40 | - login_db 41 | schema: 42 | description: 43 | - Name of the schema to add the extension to. 44 | version_added: '2.8' 45 | type: str 46 | session_role: 47 | description: 48 | - Switch to session_role after connecting. 49 | - The specified session_role must be a role that the current login_user is a member of. 50 | - Permissions checking for SQL commands is carried out as though the session_role were the one that had logged in originally. 51 | type: str 52 | version_added: '2.8' 53 | state: 54 | description: 55 | - The database extension state. 56 | default: present 57 | choices: [ absent, present ] 58 | type: str 59 | cascade: 60 | description: 61 | - Automatically install/remove any extensions that this extension depends on 62 | that are not already installed/removed (supported since PostgreSQL 9.6). 63 | type: bool 64 | default: no 65 | version_added: '2.8' 66 | login_unix_socket: 67 | description: 68 | - Path to a Unix domain socket for local connections. 69 | type: str 70 | version_added: '2.8' 71 | ssl_mode: 72 | description: 73 | - Determines whether or with what priority a secure SSL TCP/IP connection will be negotiated with the server. 74 | - See https://www.postgresql.org/docs/current/static/libpq-ssl.html for more information on the modes. 75 | - Default of C(prefer) matches libpq default. 76 | type: str 77 | default: prefer 78 | choices: [ allow, disable, prefer, require, verify-ca, verify-full ] 79 | version_added: '2.8' 80 | ca_cert: 81 | description: 82 | - Specifies the name of a file containing SSL certificate authority (CA) certificate(s). 83 | - If the file exists, the server's certificate will be verified to be signed by one of these authorities. 84 | type: str 85 | aliases: [ ssl_rootcert ] 86 | version_added: '2.8' 87 | version: 88 | description: 89 | - Extension version to add or update to. Has effect with I(state=present) only. 90 | - If not specified, the latest extension version will be created. 91 | - It can't downgrade an extension version. 92 | When version downgrade is needed, remove the extension and create new one with appropriate version. 93 | - Set I(version=latest) to update the extension to the latest available version. 94 | type: str 95 | version_added: '2.9' 96 | seealso: 97 | - name: PostgreSQL extensions 98 | description: General information about PostgreSQL extensions. 99 | link: https://www.postgresql.org/docs/current/external-extensions.html 100 | - name: CREATE EXTENSION reference 101 | description: Complete reference of the CREATE EXTENSION command documentation. 102 | link: https://www.postgresql.org/docs/current/sql-createextension.html 103 | - name: ALTER EXTENSION reference 104 | description: Complete reference of the ALTER EXTENSION command documentation. 105 | link: https://www.postgresql.org/docs/current/sql-alterextension.html 106 | - name: DROP EXTENSION reference 107 | description: Complete reference of the DROP EXTENSION command documentation. 108 | link: https://www.postgresql.org/docs/current/sql-droppublication.html 109 | notes: 110 | - The default authentication assumes that you are either logging in as 111 | or sudo'ing to the C(postgres) account on the host. 112 | - This module uses I(psycopg2), a Python PostgreSQL database adapter. 113 | - You must ensure that psycopg2 is installed on the host before using this module. 114 | - If the remote host is the PostgreSQL server (which is the default case), 115 | then PostgreSQL must also be installed on the remote host. 116 | - For Ubuntu-based systems, install the C(postgresql), C(libpq-dev), 117 | and C(python-psycopg2) packages on the remote host before using this module. 118 | requirements: [ psycopg2 ] 119 | author: 120 | - Daniel Schep (@dschep) 121 | - Thomas O'Donnell (@andytom) 122 | - Sandro Santilli (@strk) 123 | - Andrew Klychkov (@Andersson007) 124 | extends_documentation_fragment: postgres 125 | ''' 126 | 127 | EXAMPLES = r''' 128 | - name: Adds postgis extension to the database acme in the schema foo 129 | postgresql_ext: 130 | name: postgis 131 | db: acme 132 | schema: foo 133 | 134 | - name: Removes postgis extension to the database acme 135 | postgresql_ext: 136 | name: postgis 137 | db: acme 138 | state: absent 139 | 140 | - name: Adds earthdistance extension to the database template1 cascade 141 | postgresql_ext: 142 | name: earthdistance 143 | db: template1 144 | cascade: true 145 | 146 | # In the example below, if earthdistance extension is installed, 147 | # it will be removed too because it depends on cube: 148 | - name: Removes cube extension from the database acme cascade 149 | postgresql_ext: 150 | name: cube 151 | db: acme 152 | cascade: yes 153 | state: absent 154 | 155 | - name: Create extension foo of version 1.2 or update it if it's already created 156 | postgresql_ext: 157 | db: acme 158 | name: foo 159 | version: 1.2 160 | 161 | - name: Assuming extension foo is created, update it to the latest version 162 | postgresql_ext: 163 | db: acme 164 | name: foo 165 | version: latest 166 | ''' 167 | 168 | RETURN = r''' 169 | query: 170 | description: List of executed queries. 171 | returned: always 172 | type: list 173 | sample: ["DROP EXTENSION \"acme\""] 174 | 175 | ''' 176 | 177 | import traceback 178 | 179 | from distutils.version import LooseVersion 180 | 181 | 182 | 183 | from ansible.module_utils.basic import AnsibleModule 184 | from ansible.module_utils.postgres import ( 185 | connect_to_db, 186 | get_conn_params, 187 | postgres_common_argument_spec, 188 | ) 189 | from ansible.module_utils._text import to_native 190 | 191 | executed_queries = [] 192 | 193 | 194 | class NotSupportedError(Exception): 195 | pass 196 | 197 | 198 | # =========================================== 199 | # PostgreSQL module specific support methods. 200 | # 201 | 202 | def ext_exists(cursor, ext): 203 | query = "SELECT * FROM pg_extension WHERE extname=%(ext)s" 204 | cursor.execute(query, {'ext': ext}) 205 | return cursor.rowcount == 1 206 | 207 | 208 | def ext_delete(cursor, ext, cascade): 209 | if ext_exists(cursor, ext): 210 | query = "DROP EXTENSION \"%s\"" % ext 211 | if cascade: 212 | query += " CASCADE" 213 | cursor.execute(query) 214 | executed_queries.append(query) 215 | return True 216 | else: 217 | return False 218 | 219 | 220 | def ext_update_version(cursor, ext, version): 221 | """Update extension version. 222 | 223 | Return True if success. 224 | 225 | Args: 226 | cursor (cursor) -- cursor object of psycopg2 library 227 | ext (str) -- extension name 228 | version (str) -- extension version 229 | """ 230 | if version != 'latest': 231 | query = ("ALTER EXTENSION \"%s\" UPDATE TO '%s'" % (ext, version)) 232 | else: 233 | query = ("ALTER EXTENSION \"%s\" UPDATE" % ext) 234 | cursor.execute(query) 235 | executed_queries.append(query) 236 | return True 237 | 238 | 239 | def ext_create(cursor, ext, schema, cascade, version): 240 | query = "CREATE EXTENSION \"%s\"" % ext 241 | if schema: 242 | query += " WITH SCHEMA \"%s\"" % schema 243 | if version: 244 | query += " VERSION '%s'" % version 245 | if cascade: 246 | query += " CASCADE" 247 | cursor.execute(query) 248 | executed_queries.append(query) 249 | return True 250 | 251 | 252 | def ext_get_versions(cursor, ext): 253 | """ 254 | Get the current created extension version and available versions. 255 | 256 | Return tuple (current_version, [list of available versions]). 257 | 258 | Note: the list of available versions contains only versions 259 | that higher than the current created version. 260 | If the extension is not created, this list will contain all 261 | available versions. 262 | 263 | Args: 264 | cursor (cursor) -- cursor object of psycopg2 library 265 | ext (str) -- extension name 266 | """ 267 | 268 | # 1. Get the current extension version: 269 | query = ("SELECT extversion FROM pg_catalog.pg_extension " 270 | "WHERE extname = '%s'" % ext) 271 | 272 | current_version = '0' 273 | cursor.execute(query) 274 | res = cursor.fetchone() 275 | if res: 276 | current_version = res[0] 277 | 278 | # 2. Get available versions: 279 | query = ("SELECT version FROM pg_available_extension_versions " 280 | "WHERE name = '%s'" % ext) 281 | cursor.execute(query) 282 | res = cursor.fetchall() 283 | 284 | available_versions = [] 285 | if res: 286 | # Make the list of available versions: 287 | for line in res: 288 | if LooseVersion(line[0]) > LooseVersion(current_version): 289 | available_versions.append(line[0]) 290 | 291 | if current_version == '0': 292 | current_version = False 293 | 294 | return (current_version, available_versions) 295 | 296 | # =========================================== 297 | # Module execution. 298 | # 299 | 300 | 301 | def main(): 302 | argument_spec = postgres_common_argument_spec() 303 | argument_spec.update( 304 | db=dict(type="str", required=True, aliases=["login_db"]), 305 | ext=dict(type="str", required=True, aliases=["name"]), 306 | schema=dict(type="str"), 307 | state=dict(type="str", default="present", choices=["absent", "present"]), 308 | cascade=dict(type="bool", default=False), 309 | session_role=dict(type="str"), 310 | version=dict(type="str"), 311 | ) 312 | 313 | module = AnsibleModule( 314 | argument_spec=argument_spec, 315 | supports_check_mode=True, 316 | ) 317 | 318 | ext = module.params["ext"] 319 | schema = module.params["schema"] 320 | state = module.params["state"] 321 | cascade = module.params["cascade"] 322 | version = module.params["version"] 323 | changed = False 324 | 325 | if version and state == 'absent': 326 | module.warn("Parameter version is ignored when state=absent") 327 | 328 | conn_params = get_conn_params(module, module.params) 329 | db_connection = connect_to_db(module, conn_params, autocommit=True) 330 | cursor = db_connection.cursor() 331 | 332 | try: 333 | # Get extension info and available versions: 334 | curr_version, available_versions = ext_get_versions(cursor, ext) 335 | 336 | if state == "present": 337 | if version == 'latest': 338 | if available_versions: 339 | version = available_versions[-1] 340 | else: 341 | version = '' 342 | 343 | if version: 344 | # If the specific version is passed and it is not available for update: 345 | if version not in available_versions: 346 | if not curr_version: 347 | module.fail_json(msg="Passed version '%s' is not available" % version) 348 | 349 | elif LooseVersion(curr_version) == LooseVersion(version): 350 | changed = False 351 | 352 | else: 353 | module.fail_json(msg="Passed version '%s' is lower than " 354 | "the current created version '%s' or " 355 | "the passed version is not available" % (version, curr_version)) 356 | 357 | # If the specific version is passed and it is higher that the current version: 358 | if curr_version and version: 359 | if LooseVersion(curr_version) < LooseVersion(version): 360 | if module.check_mode: 361 | changed = True 362 | else: 363 | changed = ext_update_version(cursor, ext, version) 364 | 365 | # If the specific version is passed and it is created now: 366 | if curr_version == version: 367 | changed = False 368 | 369 | # If the ext doesn't exist and installed: 370 | elif not curr_version and available_versions: 371 | if module.check_mode: 372 | changed = True 373 | else: 374 | changed = ext_create(cursor, ext, schema, cascade, version) 375 | 376 | # If version is not passed: 377 | else: 378 | if not curr_version: 379 | # If the ext doesn't exist and it's installed: 380 | if available_versions: 381 | if module.check_mode: 382 | changed = True 383 | else: 384 | changed = ext_create(cursor, ext, schema, cascade, version) 385 | 386 | # If the ext doesn't exist and not installed: 387 | else: 388 | module.fail_json(msg="Extension %s is not installed" % ext) 389 | 390 | elif state == "absent": 391 | if curr_version: 392 | if module.check_mode: 393 | changed = True 394 | else: 395 | changed = ext_delete(cursor, ext, cascade) 396 | else: 397 | changed = False 398 | 399 | except Exception as e: 400 | db_connection.close() 401 | module.fail_json(msg="Database query failed: %s" % to_native(e), exception=traceback.format_exc()) 402 | 403 | db_connection.close() 404 | module.exit_json(changed=changed, db=module.params["db"], ext=ext, queries=executed_queries) 405 | 406 | 407 | if __name__ == '__main__': 408 | main() 409 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_set.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2018, Andrew Klychkov (@Andersson007) 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | 7 | # Contribution: 8 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 9 | # Welcome to https://t.me/pro_ansible for discussion and support 10 | # License: please see above 11 | 12 | from __future__ import absolute_import, division, print_function 13 | __metaclass__ = type 14 | 15 | ANSIBLE_METADATA = { 16 | 'metadata_version': '1.1', 17 | 'status': ['preview'], 18 | 'supported_by': 'community' 19 | } 20 | 21 | DOCUMENTATION = r''' 22 | --- 23 | module: postgresql_set 24 | short_description: Change a PostgreSQL server configuration parameter 25 | description: 26 | - Allows to change a PostgreSQL server configuration parameter. 27 | - The module uses ALTER SYSTEM command and applies changes by reload server configuration. 28 | - ALTER SYSTEM is used for changing server configuration parameters across the entire database cluster. 29 | - It can be more convenient and safe than the traditional method of manually editing the postgresql.conf file. 30 | - ALTER SYSTEM writes the given parameter setting to the $PGDATA/postgresql.auto.conf file, 31 | which is read in addition to postgresql.conf. 32 | - The module allows to reset parameter to boot_val (cluster initial value) by I(reset=yes) or remove parameter 33 | string from postgresql.auto.conf and reload I(value=default) (for settings with postmaster context restart is required). 34 | - After change you can see in the ansible output the previous and 35 | the new parameter value and other information using returned values and M(debug) module. 36 | version_added: '2.8' 37 | options: 38 | name: 39 | description: 40 | - Name of PostgreSQL server parameter. 41 | type: str 42 | required: true 43 | value: 44 | description: 45 | - Parameter value to set. 46 | - To remove parameter string from postgresql.auto.conf and 47 | reload the server configuration you must pass I(value=default). 48 | With I(value=default) the playbook always returns changed is true. 49 | type: str 50 | required: true 51 | reset: 52 | description: 53 | - Restore parameter to initial state (boot_val). Mutually exclusive with I(value). 54 | type: bool 55 | default: false 56 | session_role: 57 | description: 58 | - Switch to session_role after connecting. The specified session_role must 59 | be a role that the current login_user is a member of. 60 | - Permissions checking for SQL commands is carried out as though 61 | the session_role were the one that had logged in originally. 62 | type: str 63 | db: 64 | description: 65 | - Name of database to connect. 66 | type: str 67 | aliases: 68 | - login_db 69 | notes: 70 | - Supported version of PostgreSQL is 9.4 and later. 71 | - Pay attention, change setting with 'postmaster' context can return changed is true 72 | when actually nothing changes because the same value may be presented in 73 | several different form, for example, 1024MB, 1GB, etc. However in pg_settings 74 | system view it can be defined like 131072 number of 8kB pages. 75 | The final check of the parameter value cannot compare it because the server was 76 | not restarted and the value in pg_settings is not updated yet. 77 | - For some parameters restart of PostgreSQL server is required. 78 | See official documentation U(https://www.postgresql.org/docs/current/view-pg-settings.html). 79 | seealso: 80 | - module: postgresql_info 81 | - name: PostgreSQL server configuration 82 | description: General information about PostgreSQL server configuration. 83 | link: https://www.postgresql.org/docs/current/runtime-config.html 84 | - name: PostgreSQL view pg_settings reference 85 | description: Complete reference of the pg_settings view documentation. 86 | link: https://www.postgresql.org/docs/current/view-pg-settings.html 87 | - name: PostgreSQL ALTER SYSTEM command reference 88 | description: Complete reference of the ALTER SYSTEM command documentation. 89 | link: https://www.postgresql.org/docs/current/sql-altersystem.html 90 | author: 91 | - Andrew Klychkov (@Andersson007) 92 | extends_documentation_fragment: postgres 93 | ''' 94 | 95 | EXAMPLES = r''' 96 | - name: Restore wal_keep_segments parameter to initial state 97 | postgresql_set: 98 | name: wal_keep_segments 99 | reset: yes 100 | 101 | # Set work_mem parameter to 32MB and show what's been changed and restart is required or not 102 | # (output example: "msg": "work_mem 4MB >> 64MB restart_req: False") 103 | - name: Set work mem parameter 104 | postgresql_set: 105 | name: work_mem 106 | value: 32mb 107 | register: set 108 | 109 | - debug: 110 | msg: "{{ set.name }} {{ set.prev_val_pretty }} >> {{ set.value_pretty }} restart_req: {{ set.restart_required }}" 111 | when: set.changed 112 | # Ensure that the restart of PostgreSQL server must be required for some parameters. 113 | # In this situation you see the same parameter in prev_val and value_prettyue, but 'changed=True' 114 | # (If you passed the value that was different from the current server setting). 115 | 116 | - name: Set log_min_duration_statement parameter to 1 second 117 | postgresql_set: 118 | name: log_min_duration_statement 119 | value: 1s 120 | 121 | - name: Set wal_log_hints parameter to default value (remove parameter from postgresql.auto.conf) 122 | postgresql_set: 123 | name: wal_log_hints 124 | value: default 125 | ''' 126 | 127 | RETURN = r''' 128 | name: 129 | description: Name of PostgreSQL server parameter. 130 | returned: always 131 | type: str 132 | sample: 'shared_buffers' 133 | restart_required: 134 | description: Information about parameter current state. 135 | returned: always 136 | type: bool 137 | sample: true 138 | prev_val_pretty: 139 | description: Information about previous state of the parameter. 140 | returned: always 141 | type: str 142 | sample: '4MB' 143 | value_pretty: 144 | description: Information about current state of the parameter. 145 | returned: always 146 | type: str 147 | sample: '64MB' 148 | value: 149 | description: 150 | - Dictionary that contains the current parameter value (at the time of playbook finish). 151 | - Pay attention that for real change some parameters restart of PostgreSQL server is required. 152 | - Returns the current value in the check mode. 153 | returned: always 154 | type: dict 155 | sample: { "value": 67108864, "unit": "b" } 156 | context: 157 | description: 158 | - PostgreSQL setting context. 159 | returned: always 160 | type: str 161 | sample: user 162 | ''' 163 | 164 | from copy import deepcopy 165 | 166 | from ansible.module_utils.basic import AnsibleModule 167 | from ansible.module_utils.postgres import ( 168 | connect_to_db, 169 | get_conn_params, 170 | postgres_common_argument_spec, 171 | ) 172 | from ansible.module_utils._text import to_native 173 | 174 | PG_REQ_VER = 90400 175 | 176 | # To allow to set value like 1mb instead of 1MB, etc: 177 | POSSIBLE_SIZE_UNITS = ("mb", "gb", "tb") 178 | 179 | # =========================================== 180 | # PostgreSQL module specific support methods. 181 | # 182 | 183 | 184 | def param_get(cursor, module, name): 185 | query = ("SELECT name, setting, unit, context, boot_val " 186 | "FROM pg_settings WHERE name = %s") 187 | try: 188 | cursor.execute(query, [name]) 189 | info = cursor.fetchall() 190 | cursor.execute("SHOW %s" % name) 191 | val = cursor.fetchone() 192 | 193 | except Exception as e: 194 | module.fail_json(msg="Unable to get %s value due to : %s" % (name, to_native(e))) 195 | 196 | raw_val = info[0][1] 197 | unit = info[0][2] 198 | context = info[0][3] 199 | boot_val = info[0][4] 200 | 201 | if val[0] == 'True': 202 | val[0] = 'on' 203 | elif val[0] == 'False': 204 | val[0] = 'off' 205 | 206 | if unit == 'kB': 207 | if int(raw_val) > 0: 208 | raw_val = int(raw_val) * 1024 209 | if int(boot_val) > 0: 210 | boot_val = int(boot_val) * 1024 211 | 212 | unit = 'b' 213 | 214 | elif unit == 'MB': 215 | if int(raw_val) > 0: 216 | raw_val = int(raw_val) * 1024 * 1024 217 | if int(boot_val) > 0: 218 | boot_val = int(boot_val) * 1024 * 1024 219 | 220 | unit = 'b' 221 | 222 | return (val[0], raw_val, unit, boot_val, context) 223 | 224 | 225 | def pretty_to_bytes(pretty_val): 226 | # The function returns a value in bytes 227 | # if the value contains 'B', 'kB', 'MB', 'GB', 'TB'. 228 | # Otherwise it returns the passed argument. 229 | 230 | val_in_bytes = None 231 | 232 | if 'kB' in pretty_val: 233 | num_part = int(''.join(d for d in pretty_val if d.isdigit())) 234 | val_in_bytes = num_part * 1024 235 | 236 | elif 'MB' in pretty_val.upper(): 237 | num_part = int(''.join(d for d in pretty_val if d.isdigit())) 238 | val_in_bytes = num_part * 1024 * 1024 239 | 240 | elif 'GB' in pretty_val.upper(): 241 | num_part = int(''.join(d for d in pretty_val if d.isdigit())) 242 | val_in_bytes = num_part * 1024 * 1024 * 1024 243 | 244 | elif 'TB' in pretty_val.upper(): 245 | num_part = int(''.join(d for d in pretty_val if d.isdigit())) 246 | val_in_bytes = num_part * 1024 * 1024 * 1024 * 1024 247 | 248 | elif 'B' in pretty_val.upper(): 249 | num_part = int(''.join(d for d in pretty_val if d.isdigit())) 250 | val_in_bytes = num_part 251 | 252 | else: 253 | return pretty_val 254 | 255 | return val_in_bytes 256 | 257 | 258 | def param_set(cursor, module, name, value, context): 259 | try: 260 | if str(value).lower() == 'default': 261 | query = "ALTER SYSTEM SET %s = DEFAULT" % name 262 | else: 263 | query = "ALTER SYSTEM SET %s = '%s'" % (name, value) 264 | cursor.execute(query) 265 | 266 | if context != 'postmaster': 267 | cursor.execute("SELECT pg_reload_conf()") 268 | 269 | except Exception as e: 270 | module.fail_json(msg="Unable to get %s value due to : %s" % (name, to_native(e))) 271 | 272 | return True 273 | 274 | 275 | # =========================================== 276 | # Module execution. 277 | # 278 | 279 | 280 | def main(): 281 | argument_spec = postgres_common_argument_spec() 282 | argument_spec.update( 283 | name=dict(type='str', required=True), 284 | db=dict(type='str', aliases=['login_db']), 285 | value=dict(type='str'), 286 | reset=dict(type='bool'), 287 | session_role=dict(type='str'), 288 | ) 289 | module = AnsibleModule( 290 | argument_spec=argument_spec, 291 | supports_check_mode=True, 292 | ) 293 | 294 | name = module.params["name"] 295 | value = module.params["value"] 296 | reset = module.params["reset"] 297 | 298 | # Allow to pass values like 1mb instead of 1MB, etc: 299 | if value: 300 | for unit in POSSIBLE_SIZE_UNITS: 301 | if value[:-2].isdigit() and unit in value[-2:]: 302 | value = value.upper() 303 | 304 | if value and reset: 305 | module.fail_json(msg="%s: value and reset params are mutually exclusive" % name) 306 | 307 | if not value and not reset: 308 | module.fail_json(msg="%s: at least one of value or reset param must be specified" % name) 309 | 310 | conn_params = get_conn_params(module, module.params, warn_db_default=False) 311 | db_connection = connect_to_db(module, conn_params, autocommit=True) 312 | cursor = db_connection.cursor() 313 | 314 | kw = {} 315 | # Check server version (needs 9.4 or later): 316 | 317 | ver = db_connection.server_version 318 | if ver < PG_REQ_VER: 319 | module.warn("PostgreSQL is %s version but %s or later is required" % (ver, PG_REQ_VER)) 320 | kw = dict( 321 | changed=False, 322 | restart_required=False, 323 | value_pretty="", 324 | prev_val_pretty="", 325 | value={"value": "", "unit": ""}, 326 | ) 327 | kw['name'] = name 328 | db_connection.close() 329 | module.exit_json(**kw) 330 | 331 | # Set default returned values: 332 | restart_required = False 333 | changed = False 334 | kw['name'] = name 335 | kw['restart_required'] = False 336 | 337 | # Get info about param state: 338 | res = param_get(cursor, module, name) 339 | current_value = res[0] 340 | raw_val = res[1] 341 | unit = res[2] 342 | boot_val = res[3] 343 | context = res[4] 344 | 345 | if value == 'True': 346 | value = 'on' 347 | elif value == 'False': 348 | value = 'off' 349 | 350 | kw['prev_val_pretty'] = current_value 351 | kw['value_pretty'] = deepcopy(kw['prev_val_pretty']) 352 | kw['context'] = context 353 | 354 | # Do job 355 | if context == "internal": 356 | module.fail_json(msg="%s: cannot be changed (internal context). See " 357 | "https://www.postgresql.org/docs/current/runtime-config-preset.html" % name) 358 | 359 | if context == "postmaster": 360 | restart_required = True 361 | 362 | # If check_mode, just compare and exit: 363 | if module.check_mode: 364 | if pretty_to_bytes(value) == pretty_to_bytes(current_value): 365 | kw['changed'] = False 366 | 367 | else: 368 | kw['value_pretty'] = value 369 | kw['changed'] = True 370 | 371 | # Anyway returns current raw value in the check_mode: 372 | kw['value'] = dict( 373 | value=raw_val, 374 | unit=unit, 375 | ) 376 | kw['restart_required'] = restart_required 377 | module.exit_json(**kw) 378 | 379 | # Set param: 380 | if value and value != current_value: 381 | changed = param_set(cursor, module, name, value, context) 382 | 383 | kw['value_pretty'] = value 384 | 385 | # Reset param: 386 | elif reset: 387 | if raw_val == boot_val: 388 | # nothing to change, exit: 389 | kw['value'] = dict( 390 | value=raw_val, 391 | unit=unit, 392 | ) 393 | module.exit_json(**kw) 394 | 395 | changed = param_set(cursor, module, name, boot_val, context) 396 | 397 | if restart_required: 398 | module.warn("Restart of PostgreSQL is required for setting %s" % name) 399 | 400 | cursor.close() 401 | db_connection.close() 402 | 403 | # Reconnect and recheck current value: 404 | if context in ('sighup', 'superuser-backend', 'backend', 'superuser', 'user'): 405 | db_connection = connect_to_db(module, conn_params, autocommit=True) 406 | cursor = db_connection.cursor() 407 | 408 | res = param_get(cursor, module, name) 409 | # f_ means 'final' 410 | f_value = res[0] 411 | f_raw_val = res[1] 412 | 413 | if raw_val == f_raw_val: 414 | changed = False 415 | 416 | else: 417 | changed = True 418 | 419 | kw['value_pretty'] = f_value 420 | kw['value'] = dict( 421 | value=f_raw_val, 422 | unit=unit, 423 | ) 424 | 425 | cursor.close() 426 | db_connection.close() 427 | 428 | kw['changed'] = changed 429 | kw['restart_required'] = restart_required 430 | module.exit_json(**kw) 431 | 432 | 433 | if __name__ == '__main__': 434 | main() 435 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_owner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2019, Andrew Klychkov (@Andersson007) 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | 7 | # Contribution: 8 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 9 | # Welcome to https://t.me/pro_ansible for discussion and support 10 | # License: please see above 11 | 12 | from __future__ import (absolute_import, division, print_function) 13 | __metaclass__ = type 14 | 15 | ANSIBLE_METADATA = { 16 | 'metadata_version': '1.1', 17 | 'supported_by': 'community', 18 | 'status': ['preview'] 19 | } 20 | 21 | DOCUMENTATION = r''' 22 | --- 23 | module: postgresql_owner 24 | short_description: Change an owner of PostgreSQL database object 25 | description: 26 | - Change an owner of PostgreSQL database object. 27 | - Also allows to reassign the ownership of database objects owned by a database role to another role. 28 | version_added: '2.8' 29 | 30 | options: 31 | new_owner: 32 | description: 33 | - Role (user/group) to set as an I(obj_name) owner. 34 | type: str 35 | required: yes 36 | obj_name: 37 | description: 38 | - Name of a database object to change ownership. 39 | - Mutually exclusive with I(reassign_owned_by). 40 | type: str 41 | obj_type: 42 | description: 43 | - Type of a database object. 44 | - Mutually exclusive with I(reassign_owned_by). 45 | type: str 46 | required: yes 47 | choices: [ database, function, matview, sequence, schema, table, tablespace, view ] 48 | aliases: 49 | - type 50 | reassign_owned_by: 51 | description: 52 | - The list of role names. The ownership of all the objects within the current database, 53 | and of all shared objects (databases, tablespaces), owned by this role(s) will be reassigned to I(owner). 54 | - Pay attention - it reassigns all objects owned by this role(s) in the I(db)! 55 | - If role(s) exists, always returns changed True. 56 | - Cannot reassign ownership of objects that are required by the database system. 57 | - Mutually exclusive with C(obj_type). 58 | type: list 59 | elements: str 60 | fail_on_role: 61 | description: 62 | - If C(yes), fail when I(reassign_owned_by) role does not exist. 63 | Otherwise just warn and continue. 64 | - Mutually exclusive with I(obj_name) and I(obj_type). 65 | default: yes 66 | type: bool 67 | db: 68 | description: 69 | - Name of database to connect to. 70 | type: str 71 | aliases: 72 | - login_db 73 | session_role: 74 | description: 75 | - Switch to session_role after connecting. 76 | The specified session_role must be a role that the current login_user is a member of. 77 | - Permissions checking for SQL commands is carried out as though 78 | the session_role were the one that had logged in originally. 79 | type: str 80 | seealso: 81 | - module: postgresql_user 82 | - module: postgresql_privs 83 | - module: postgresql_membership 84 | - name: PostgreSQL REASSIGN OWNED command reference 85 | description: Complete reference of the PostgreSQL REASSIGN OWNED command documentation. 86 | link: https://www.postgresql.org/docs/current/sql-reassign-owned.html 87 | author: 88 | - Andrew Klychkov (@Andersson007) 89 | extends_documentation_fragment: postgres 90 | ''' 91 | 92 | EXAMPLES = r''' 93 | # Set owner as alice for function myfunc in database bar by ansible ad-hoc command: 94 | # ansible -m postgresql_owner -a "db=bar new_owner=alice obj_name=myfunc obj_type=function" 95 | 96 | - name: The same as above by playbook 97 | postgresql_owner: 98 | db: bar 99 | new_owner: alice 100 | obj_name: myfunc 101 | obj_type: function 102 | 103 | - name: Set owner as bob for table acme in database bar 104 | postgresql_owner: 105 | db: bar 106 | new_owner: bob 107 | obj_name: acme 108 | obj_type: table 109 | 110 | - name: Set owner as alice for view test_view in database bar 111 | postgresql_owner: 112 | db: bar 113 | new_owner: alice 114 | obj_name: test_view 115 | obj_type: view 116 | 117 | - name: Set owner as bob for tablespace ssd in database foo 118 | postgresql_owner: 119 | db: foo 120 | new_owner: bob 121 | obj_name: ssd 122 | obj_type: tablespace 123 | 124 | - name: Reassign all object in database bar owned by bob to alice 125 | postgresql_owner: 126 | db: bar 127 | new_owner: alice 128 | reassign_owned_by: bob 129 | 130 | - name: Reassign all object in database bar owned by bob and bill to alice 131 | postgresql_owner: 132 | db: bar 133 | new_owner: alice 134 | reassign_owned_by: 135 | - bob 136 | - bill 137 | ''' 138 | 139 | RETURN = r''' 140 | queries: 141 | description: List of executed queries. 142 | returned: always 143 | type: str 144 | sample: [ 'REASSIGN OWNED BY "bob" TO "alice"' ] 145 | ''' 146 | 147 | from ansible.module_utils.basic import AnsibleModule 148 | from ansible.module_utils.database import pg_quote_identifier 149 | from ansible.module_utils.postgres import ( 150 | connect_to_db, 151 | exec_sql, 152 | get_conn_params, 153 | postgres_common_argument_spec, 154 | ) 155 | 156 | 157 | class PgOwnership(object): 158 | 159 | """Class for changing ownership of PostgreSQL objects. 160 | 161 | Arguments: 162 | module (AnsibleModule): Object of Ansible module class. 163 | cursor (psycopg2.connect.cursor): Cursor object for interaction with the database. 164 | role (str): Role name to set as a new owner of objects. 165 | 166 | Important: 167 | If you want to add handling of a new type of database objects: 168 | 1. Add a specific method for this like self.__set_db_owner(), etc. 169 | 2. Add a condition with a check of ownership for new type objects to self.__is_owner() 170 | 3. Add a condition with invocation of the specific method to self.set_owner() 171 | 4. Add the information to the module documentation 172 | That's all. 173 | """ 174 | 175 | def __init__(self, module, cursor, role): 176 | self.module = module 177 | self.cursor = cursor 178 | self.check_role_exists(role) 179 | self.role = role 180 | self.changed = False 181 | self.executed_queries = [] 182 | self.obj_name = '' 183 | self.obj_type = '' 184 | 185 | def check_role_exists(self, role, fail_on_role=True): 186 | """Check the role exists or not. 187 | 188 | Arguments: 189 | role (str): Role name. 190 | fail_on_role (bool): If True, fail when the role does not exist. 191 | Otherwise just warn and continue. 192 | """ 193 | if not self.__role_exists(role): 194 | if fail_on_role: 195 | self.module.fail_json(msg="Role '%s' does not exist" % role) 196 | else: 197 | self.module.warn("Role '%s' does not exist, pass" % role) 198 | 199 | return False 200 | 201 | else: 202 | return True 203 | 204 | def reassign(self, old_owners, fail_on_role): 205 | """Implements REASSIGN OWNED BY command. 206 | 207 | If success, set self.changed as True. 208 | 209 | Arguments: 210 | old_owners (list): The ownership of all the objects within 211 | the current database, and of all shared objects (databases, tablespaces), 212 | owned by these roles will be reassigned to self.role. 213 | fail_on_role (bool): If True, fail when a role from old_owners does not exist. 214 | Otherwise just warn and continue. 215 | """ 216 | roles = [] 217 | for r in old_owners: 218 | if self.check_role_exists(r, fail_on_role): 219 | roles.append(pg_quote_identifier(r, 'role')) 220 | 221 | # Roles do not exist, nothing to do, exit: 222 | if not roles: 223 | return False 224 | 225 | old_owners = ','.join(roles) 226 | 227 | query = ['REASSIGN OWNED BY'] 228 | query.append(old_owners) 229 | query.append('TO %s' % pg_quote_identifier(self.role, 'role')) 230 | query = ' '.join(query) 231 | 232 | self.changed = exec_sql(self, query, ddl=True) 233 | 234 | def set_owner(self, obj_type, obj_name): 235 | """Change owner of a database object. 236 | 237 | Arguments: 238 | obj_type (str): Type of object (like database, table, view, etc.). 239 | obj_name (str): Object name. 240 | """ 241 | self.obj_name = obj_name 242 | self.obj_type = obj_type 243 | 244 | # if a new_owner is the object owner now, 245 | # nothing to do: 246 | if self.__is_owner(): 247 | return False 248 | 249 | if obj_type == 'database': 250 | self.__set_db_owner() 251 | 252 | elif obj_type == 'function': 253 | self.__set_func_owner() 254 | 255 | elif obj_type == 'sequence': 256 | self.__set_seq_owner() 257 | 258 | elif obj_type == 'schema': 259 | self.__set_schema_owner() 260 | 261 | elif obj_type == 'table': 262 | self.__set_table_owner() 263 | 264 | elif obj_type == 'tablespace': 265 | self.__set_tablespace_owner() 266 | 267 | elif obj_type == 'view': 268 | self.__set_view_owner() 269 | 270 | elif obj_type == 'matview': 271 | self.__set_mat_view_owner() 272 | 273 | def __is_owner(self): 274 | """Return True if self.role is the current object owner.""" 275 | if self.obj_type == 'table': 276 | query = ("SELECT 1 FROM pg_tables " 277 | "WHERE tablename = %s " 278 | "AND tableowner = %s") 279 | 280 | elif self.obj_type == 'database': 281 | query = ("SELECT 1 FROM pg_database AS d " 282 | "JOIN pg_roles AS r ON d.datdba = r.oid " 283 | "WHERE d.datname = %s " 284 | "AND r.rolname = %s") 285 | 286 | elif self.obj_type == 'function': 287 | query = ("SELECT 1 FROM pg_proc AS f " 288 | "JOIN pg_roles AS r ON f.proowner = r.oid " 289 | "WHERE f.proname = %s " 290 | "AND r.rolname = %s") 291 | 292 | elif self.obj_type == 'sequence': 293 | query = ("SELECT 1 FROM pg_class AS c " 294 | "JOIN pg_roles AS r ON c.relowner = r.oid " 295 | "WHERE c.relkind = 'S' AND c.relname = %s " 296 | "AND r.rolname = %s") 297 | 298 | elif self.obj_type == 'schema': 299 | query = ("SELECT 1 FROM information_schema.schemata " 300 | "WHERE schema_name = %s " 301 | "AND schema_owner = %s") 302 | 303 | elif self.obj_type == 'tablespace': 304 | query = ("SELECT 1 FROM pg_tablespace AS t " 305 | "JOIN pg_roles AS r ON t.spcowner = r.oid " 306 | "WHERE t.spcname = %s " 307 | "AND r.rolname = %s") 308 | 309 | elif self.obj_type == 'view': 310 | query = ("SELECT 1 FROM pg_views " 311 | "WHERE viewname = %s " 312 | "AND viewowner = %s") 313 | 314 | elif self.obj_type == 'matview': 315 | query = ("SELECT 1 FROM pg_matviews " 316 | "WHERE matviewname = %s " 317 | "AND matviewowner = %s") 318 | 319 | return exec_sql(self, query, (self.obj_name,self.role,), add_to_executed=False) 320 | 321 | def __set_db_owner(self): 322 | """Set the database owner.""" 323 | query = "ALTER DATABASE %s OWNER TO %s" % (pg_quote_identifier(self.obj_name, 'database'), 324 | pg_quote_identifier(self.role, 'role')) 325 | self.changed = exec_sql(self, query, ddl=True) 326 | 327 | def __set_func_owner(self): 328 | """Set the function owner.""" 329 | query = "ALTER FUNCTION %s OWNER TO %s" % (self.obj_name, 330 | pg_quote_identifier(self.role, 'role')) 331 | self.changed = exec_sql(self, query, ddl=True) 332 | 333 | def __set_seq_owner(self): 334 | """Set the sequence owner.""" 335 | query = "ALTER SEQUENCE %s OWNER TO %s" % (pg_quote_identifier(self.obj_name, 'table'), 336 | pg_quote_identifier(self.role, 'role')) 337 | self.changed = exec_sql(self, query, ddl=True) 338 | 339 | def __set_schema_owner(self): 340 | """Set the schema owner.""" 341 | query = "ALTER SCHEMA %s OWNER TO %s" % (pg_quote_identifier(self.obj_name, 'schema'), 342 | pg_quote_identifier(self.role, 'role')) 343 | self.changed = exec_sql(self, query, ddl=True) 344 | 345 | def __set_table_owner(self): 346 | """Set the table owner.""" 347 | query = "ALTER TABLE %s OWNER TO %s" % (pg_quote_identifier(self.obj_name, 'table'), 348 | pg_quote_identifier(self.role, 'role')) 349 | self.changed = exec_sql(self, query, ddl=True) 350 | 351 | def __set_tablespace_owner(self): 352 | """Set the tablespace owner.""" 353 | query = "ALTER TABLESPACE %s OWNER TO %s" % (pg_quote_identifier(self.obj_name, 'database'), 354 | pg_quote_identifier(self.role, 'role')) 355 | self.changed = exec_sql(self, query, ddl=True) 356 | 357 | def __set_view_owner(self): 358 | """Set the view owner.""" 359 | query = "ALTER VIEW %s OWNER TO %s" % (pg_quote_identifier(self.obj_name, 'table'), 360 | pg_quote_identifier(self.role, 'role')) 361 | self.changed = exec_sql(self, query, ddl=True) 362 | 363 | def __set_mat_view_owner(self): 364 | """Set the materialized view owner.""" 365 | query = "ALTER MATERIALIZED VIEW %s OWNER TO %s" % (pg_quote_identifier(self.obj_name, 'table'), 366 | pg_quote_identifier(self.role, 'role')) 367 | self.changed = exec_sql(self, query, ddl=True) 368 | 369 | def __role_exists(self, role): 370 | """Return True if role exists, otherwise return False.""" 371 | query_params = (role,) 372 | query = "SELECT 1 FROM pg_roles WHERE rolname = %s" 373 | return exec_sql(self, query, query_params, add_to_executed=False) 374 | 375 | 376 | # =========================================== 377 | # Module execution. 378 | # 379 | 380 | 381 | def main(): 382 | argument_spec = postgres_common_argument_spec() 383 | argument_spec.update( 384 | new_owner=dict(type='str', required=True), 385 | obj_name=dict(type='str'), 386 | obj_type=dict(type='str', aliases=['type'], choices=[ 387 | 'database', 'function', 'matview', 'sequence', 'schema', 'table', 'tablespace', 'view']), 388 | reassign_owned_by=dict(type='list'), 389 | fail_on_role=dict(type='bool', default=True), 390 | db=dict(type='str', aliases=['login_db']), 391 | session_role=dict(type='str'), 392 | ) 393 | module = AnsibleModule( 394 | argument_spec=argument_spec, 395 | mutually_exclusive=[ 396 | ['obj_name', 'reassign_owned_by'], 397 | ['obj_type', 'reassign_owned_by'], 398 | ['obj_name', 'fail_on_role'], 399 | ['obj_type', 'fail_on_role'], 400 | ], 401 | supports_check_mode=True, 402 | ) 403 | 404 | new_owner = module.params['new_owner'] 405 | obj_name = module.params['obj_name'] 406 | obj_type = module.params['obj_type'] 407 | reassign_owned_by = module.params['reassign_owned_by'] 408 | fail_on_role = module.params['fail_on_role'] 409 | 410 | conn_params = get_conn_params(module, module.params) 411 | db_connection = connect_to_db(module, conn_params, autocommit=False) 412 | cursor = db_connection.cursor() 413 | 414 | ############## 415 | # Create the object and do main job: 416 | pg_ownership = PgOwnership(module, cursor, new_owner) 417 | 418 | # if we want to change ownership: 419 | if obj_name: 420 | pg_ownership.set_owner(obj_type, obj_name) 421 | 422 | # if we want to reassign objects owned by roles: 423 | elif reassign_owned_by: 424 | pg_ownership.reassign(reassign_owned_by, fail_on_role) 425 | 426 | # Rollback if it's possible and check_mode: 427 | if module.check_mode: 428 | db_connection.rollback() 429 | else: 430 | db_connection.commit() 431 | 432 | cursor.close() 433 | db_connection.close() 434 | 435 | module.exit_json( 436 | changed=pg_ownership.changed, 437 | queries=pg_ownership.executed_queries, 438 | ) 439 | 440 | 441 | if __name__ == '__main__': 442 | main() 443 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_tablespace.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2017, Flavien Chantelot (@Dorn-) 5 | # Copyright: (c) 2018, Antoine Levy-Lambert (@antoinell) 6 | # Copyright: (c) 2019, Andrew Klychkov (@Andersson007) 7 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 8 | 9 | # Contribution: 10 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 11 | # Welcome to https://t.me/pro_ansible for discussion and support 12 | # License: please see above 13 | 14 | from __future__ import (absolute_import, division, print_function) 15 | __metaclass__ = type 16 | 17 | ANSIBLE_METADATA = { 18 | 'metadata_version': '1.1', 19 | 'supported_by': 'community', 20 | 'status': ['preview'] 21 | } 22 | 23 | DOCUMENTATION = r''' 24 | --- 25 | module: postgresql_tablespace 26 | short_description: Add or remove PostgreSQL tablespaces from remote hosts 27 | description: 28 | - Adds or removes PostgreSQL tablespaces from remote hosts. 29 | version_added: '2.8' 30 | options: 31 | tablespace: 32 | description: 33 | - Name of the tablespace to add or remove. 34 | required: true 35 | type: str 36 | aliases: 37 | - name 38 | location: 39 | description: 40 | - Path to the tablespace directory in the file system. 41 | - Ensure that the location exists and has right privileges. 42 | type: path 43 | aliases: 44 | - path 45 | state: 46 | description: 47 | - Tablespace state. 48 | - I(state=present) implies the tablespace must be created if it doesn't exist. 49 | - I(state=absent) implies the tablespace must be removed if present. 50 | I(state=absent) is mutually exclusive with I(location), I(owner), i(set). 51 | - See the Notes section for information about check mode restrictions. 52 | type: str 53 | default: present 54 | choices: [ absent, present ] 55 | owner: 56 | description: 57 | - Name of the role to set as an owner of the tablespace. 58 | - If this option is not specified, the tablespace owner is a role that creates the tablespace. 59 | type: str 60 | set: 61 | description: 62 | - Dict of tablespace options to set. Supported from PostgreSQL 9.0. 63 | - For more information see U(https://www.postgresql.org/docs/current/sql-createtablespace.html). 64 | - When reset is passed as an option's value, if the option was set previously, it will be removed. 65 | type: dict 66 | rename_to: 67 | description: 68 | - New name of the tablespace. 69 | - The new name cannot begin with pg_, as such names are reserved for system tablespaces. 70 | session_role: 71 | description: 72 | - Switch to session_role after connecting. The specified session_role must 73 | be a role that the current login_user is a member of. 74 | - Permissions checking for SQL commands is carried out as though 75 | the session_role were the one that had logged in originally. 76 | type: str 77 | db: 78 | description: 79 | - Name of database to connect to and run queries against. 80 | type: str 81 | aliases: 82 | - login_db 83 | 84 | notes: 85 | - I(state=absent) and I(state=present) (the second one if the tablespace doesn't exist) do not 86 | support check mode because the corresponding PostgreSQL DROP and CREATE TABLESPACE commands 87 | can not be run inside the transaction block. 88 | 89 | seealso: 90 | - name: PostgreSQL tablespaces 91 | description: General information about PostgreSQL tablespaces. 92 | link: https://www.postgresql.org/docs/current/manage-ag-tablespaces.html 93 | - name: CREATE TABLESPACE reference 94 | description: Complete reference of the CREATE TABLESPACE command documentation. 95 | link: https://www.postgresql.org/docs/current/sql-createtablespace.html 96 | - name: ALTER TABLESPACE reference 97 | description: Complete reference of the ALTER TABLESPACE command documentation. 98 | link: https://www.postgresql.org/docs/current/sql-altertablespace.html 99 | - name: DROP TABLESPACE reference 100 | description: Complete reference of the DROP TABLESPACE command documentation. 101 | link: https://www.postgresql.org/docs/current/sql-droptablespace.html 102 | 103 | author: 104 | - Flavien Chantelot (@Dorn-) 105 | - Antoine Levy-Lambert (@antoinell) 106 | - Andrew Klychkov (@Andersson007) 107 | 108 | extends_documentation_fragment: postgres 109 | ''' 110 | 111 | EXAMPLES = r''' 112 | - name: Create a new tablespace called acme and set bob as an its owner 113 | postgresql_tablespace: 114 | name: acme 115 | owner: bob 116 | location: /data/foo 117 | 118 | - name: Create a new tablespace called bar with tablespace options 119 | postgresql_tablespace: 120 | name: bar 121 | set: 122 | random_page_cost: 1 123 | seq_page_cost: 1 124 | 125 | - name: Reset random_page_cost option 126 | postgresql_tablespace: 127 | name: bar 128 | set: 129 | random_page_cost: reset 130 | 131 | - name: Rename the tablespace from bar to pcie_ssd 132 | postgresql_tablespace: 133 | name: bar 134 | rename_to: pcie_ssd 135 | 136 | - name: Drop tablespace called bloat 137 | postgresql_tablespace: 138 | name: bloat 139 | state: absent 140 | ''' 141 | 142 | RETURN = r''' 143 | queries: 144 | description: List of queries that was tried to be executed. 145 | returned: always 146 | type: str 147 | sample: [ "CREATE TABLESPACE bar LOCATION '/incredible/ssd'" ] 148 | tablespace: 149 | description: Tablespace name. 150 | returned: always 151 | type: str 152 | sample: 'ssd' 153 | owner: 154 | description: Tablespace owner. 155 | returned: always 156 | type: str 157 | sample: 'Bob' 158 | options: 159 | description: Tablespace options. 160 | returned: always 161 | type: dict 162 | sample: { 'random_page_cost': 1, 'seq_page_cost': 1 } 163 | location: 164 | description: Path to the tablespace in the file system. 165 | returned: always 166 | type: str 167 | sample: '/incredible/fast/ssd' 168 | newname: 169 | description: New tablespace name 170 | returned: if existent 171 | type: str 172 | sample: new_ssd 173 | state: 174 | description: Tablespace state at the end of execution. 175 | returned: always 176 | type: str 177 | sample: 'present' 178 | ''' 179 | 180 | from ansible.module_utils.basic import AnsibleModule 181 | from ansible.module_utils.database import pg_quote_identifier 182 | from ansible.module_utils.postgres import ( 183 | connect_to_db, 184 | exec_sql, 185 | get_conn_params, 186 | postgres_common_argument_spec, 187 | ) 188 | 189 | 190 | class PgTablespace(object): 191 | 192 | """Class for working with PostgreSQL tablespaces. 193 | 194 | Args: 195 | module (AnsibleModule) -- object of AnsibleModule class 196 | cursor (cursor) -- cursor object of psycopg2 library 197 | name (str) -- name of the tablespace 198 | 199 | Attrs: 200 | module (AnsibleModule) -- object of AnsibleModule class 201 | cursor (cursor) -- cursor object of psycopg2 library 202 | name (str) -- name of the tablespace 203 | exists (bool) -- flag the tablespace exists in the DB or not 204 | owner (str) -- tablespace owner 205 | location (str) -- path to the tablespace directory in the file system 206 | executed_queries (list) -- list of executed queries 207 | new_name (str) -- new name for the tablespace 208 | opt_not_supported (bool) -- flag indicates a tablespace option is supported or not 209 | """ 210 | 211 | def __init__(self, module, cursor, name): 212 | self.module = module 213 | self.cursor = cursor 214 | self.name = name 215 | self.exists = False 216 | self.owner = '' 217 | self.settings = {} 218 | self.location = '' 219 | self.executed_queries = [] 220 | self.new_name = '' 221 | self.opt_not_supported = False 222 | # Collect info: 223 | self.get_info() 224 | 225 | def get_info(self): 226 | """Get tablespace information.""" 227 | # Check that spcoptions exists: 228 | opt = exec_sql(self, "SELECT 1 FROM information_schema.columns " 229 | "WHERE table_name = 'pg_tablespace' " 230 | "AND column_name = 'spcoptions'", add_to_executed=False) 231 | 232 | # For 9.1 version and earlier: 233 | location = exec_sql(self, "SELECT 1 FROM information_schema.columns " 234 | "WHERE table_name = 'pg_tablespace' " 235 | "AND column_name = 'spclocation'", add_to_executed=False) 236 | if location: 237 | location = 'spclocation' 238 | else: 239 | location = 'pg_tablespace_location(t.oid)' 240 | 241 | if not opt: 242 | self.opt_not_supported = True 243 | query = ("SELECT r.rolname, (SELECT Null), %s " 244 | "FROM pg_catalog.pg_tablespace AS t " 245 | "JOIN pg_catalog.pg_roles AS r " 246 | "ON t.spcowner = r.oid " 247 | "WHERE t.spcname = '%s'" % (location, self.name)) 248 | else: 249 | query = ("SELECT r.rolname, t.spcoptions, %s " 250 | "FROM pg_catalog.pg_tablespace AS t " 251 | "JOIN pg_catalog.pg_roles AS r " 252 | "ON t.spcowner = r.oid " 253 | "WHERE t.spcname = '%s'" % (location, self.name)) 254 | 255 | res = exec_sql(self, query, add_to_executed=False) 256 | 257 | if not res: 258 | self.exists = False 259 | return False 260 | 261 | if res[0][0]: 262 | self.exists = True 263 | self.owner = res[0][0] 264 | 265 | if res[0][1]: 266 | # Options exist: 267 | for i in res[0][1]: 268 | i = i.split('=') 269 | self.settings[i[0]] = i[1] 270 | 271 | if res[0][2]: 272 | # Location exists: 273 | self.location = res[0][2] 274 | 275 | def create(self, location): 276 | """Create tablespace. 277 | 278 | Return True if success, otherwise, return False. 279 | 280 | args: 281 | location (str) -- tablespace directory path in the FS 282 | """ 283 | query = ("CREATE TABLESPACE %s LOCATION '%s'" % (pg_quote_identifier(self.name, 'database'), location)) 284 | return exec_sql(self, query, ddl=True) 285 | 286 | def drop(self): 287 | """Drop tablespace. 288 | 289 | Return True if success, otherwise, return False. 290 | """ 291 | return exec_sql(self, "DROP TABLESPACE %s" % pg_quote_identifier(self.name, 'database'), ddl=True) 292 | 293 | def set_owner(self, new_owner): 294 | """Set tablespace owner. 295 | 296 | Return True if success, otherwise, return False. 297 | 298 | args: 299 | new_owner (str) -- name of a new owner for the tablespace" 300 | """ 301 | if new_owner == self.owner: 302 | return False 303 | 304 | query = "ALTER TABLESPACE %s OWNER TO %s" % (pg_quote_identifier(self.name, 'database'), new_owner) 305 | return exec_sql(self, query, ddl=True) 306 | 307 | def rename(self, newname): 308 | """Rename tablespace. 309 | 310 | Return True if success, otherwise, return False. 311 | 312 | args: 313 | newname (str) -- new name for the tablespace" 314 | """ 315 | query = "ALTER TABLESPACE %s RENAME TO %s" % (pg_quote_identifier(self.name, 'database'), newname) 316 | self.new_name = newname 317 | return exec_sql(self, query, ddl=True) 318 | 319 | def set_settings(self, new_settings): 320 | """Set tablespace settings (options). 321 | 322 | If some setting has been changed, set changed = True. 323 | After all settings list is handling, return changed. 324 | 325 | args: 326 | new_settings (list) -- list of new settings 327 | """ 328 | # settings must be a dict {'key': 'value'} 329 | if self.opt_not_supported: 330 | return False 331 | 332 | changed = False 333 | 334 | # Apply new settings: 335 | for i in new_settings: 336 | if new_settings[i] == 'reset': 337 | if i in self.settings: 338 | changed = self.__reset_setting(i) 339 | self.settings[i] = None 340 | 341 | elif (i not in self.settings) or (str(new_settings[i]) != self.settings[i]): 342 | changed = self.__set_setting("%s = '%s'" % (i, new_settings[i])) 343 | 344 | return changed 345 | 346 | def __reset_setting(self, setting): 347 | """Reset tablespace setting. 348 | 349 | Return True if success, otherwise, return False. 350 | 351 | args: 352 | setting (str) -- string in format "setting_name = 'setting_value'" 353 | """ 354 | query = "ALTER TABLESPACE %s RESET (%s)" % (pg_quote_identifier(self.name, 'database'), setting) 355 | return exec_sql(self, query, ddl=True) 356 | 357 | def __set_setting(self, setting): 358 | """Set tablespace setting. 359 | 360 | Return True if success, otherwise, return False. 361 | 362 | args: 363 | setting (str) -- string in format "setting_name = 'setting_value'" 364 | """ 365 | query = "ALTER TABLESPACE %s SET (%s)" % (pg_quote_identifier(self.name, 'database'), setting) 366 | return exec_sql(self, query, ddl=True) 367 | 368 | 369 | # =========================================== 370 | # Module execution. 371 | # 372 | 373 | 374 | def main(): 375 | argument_spec = postgres_common_argument_spec() 376 | argument_spec.update( 377 | tablespace=dict(type='str', aliases=['name']), 378 | state=dict(type='str', default="present", choices=["absent", "present"]), 379 | location=dict(type='path', aliases=['path']), 380 | owner=dict(type='str'), 381 | set=dict(type='dict'), 382 | rename_to=dict(type='str'), 383 | db=dict(type='str', aliases=['login_db']), 384 | session_role=dict(type='str'), 385 | ) 386 | 387 | module = AnsibleModule( 388 | argument_spec=argument_spec, 389 | mutually_exclusive=(('positional_args', 'named_args'),), 390 | supports_check_mode=True, 391 | ) 392 | 393 | tablespace = module.params["tablespace"] 394 | state = module.params["state"] 395 | location = module.params["location"] 396 | owner = module.params["owner"] 397 | rename_to = module.params["rename_to"] 398 | settings = module.params["set"] 399 | 400 | if state == 'absent' and (location or owner or rename_to or settings): 401 | module.fail_json(msg="state=absent is mutually exclusive location, " 402 | "owner, rename_to, and set") 403 | 404 | conn_params = get_conn_params(module, module.params, warn_db_default=False) 405 | db_connection = connect_to_db(module, conn_params, autocommit=True) 406 | cursor = db_connection.cursor() 407 | 408 | # Change autocommit to False if check_mode: 409 | if module.check_mode: 410 | db_connection.autocommit=False 411 | 412 | # Set defaults: 413 | autocommit = False 414 | changed = False 415 | 416 | ############## 417 | # Create PgTablespace object and do main job: 418 | tblspace = PgTablespace(module, cursor, tablespace) 419 | 420 | # If tablespace exists with different location, exit: 421 | if tblspace.exists and location and location != tblspace.location: 422 | module.fail_json(msg="Tablespace '%s' exists with different location '%s'" % (tblspace.name, tblspace.location)) 423 | 424 | # Create new tablespace: 425 | if not tblspace.exists and state == 'present': 426 | if rename_to: 427 | module.fail_json(msg="Tablespace %s does not exist, nothing to rename" % tablespace) 428 | 429 | if not location: 430 | module.fail_json(msg="'location' parameter must be passed with " 431 | "state=present if the tablespace doesn't exist") 432 | 433 | # Because CREATE TABLESPACE can not be run inside the transaction block: 434 | autocommit = True 435 | db_connection.autocommit=True 436 | 437 | changed = tblspace.create(location) 438 | 439 | # Drop non-existing tablespace: 440 | elif not tblspace.exists and state == 'absent': 441 | # Nothing to do: 442 | #???? module.fail_json(msg="Tries to drop nonexistent tablespace '%s'" % tblspace.name) 443 | changed = False 444 | 445 | # Drop existing tablespace: 446 | elif tblspace.exists and state == 'absent': 447 | # Because DROP TABLESPACE can not be run inside the transaction block: 448 | autocommit = True 449 | db_connection.autocommit=True 450 | changed = tblspace.drop() 451 | 452 | # Rename tablespace: 453 | elif tblspace.exists and rename_to: 454 | if tblspace.name != rename_to: 455 | changed = tblspace.rename(rename_to) 456 | 457 | if state == 'present': 458 | # Refresh information: 459 | tblspace.get_info() 460 | 461 | # Change owner and settings: 462 | if state == 'present' and tblspace.exists: 463 | if owner: 464 | changed = tblspace.set_owner(owner) 465 | 466 | if settings: 467 | changed = tblspace.set_settings(settings) 468 | 469 | tblspace.get_info() 470 | 471 | # Rollback if it's possible and check_mode: 472 | if not autocommit: 473 | if module.check_mode: 474 | db_connection.rollback() 475 | else: 476 | db_connection.commit() 477 | 478 | cursor.close() 479 | db_connection.close() 480 | 481 | # Make return values: 482 | kw = dict( 483 | changed=changed, 484 | state=state, 485 | tablespace=tblspace.name, 486 | owner=tblspace.owner, 487 | queries=tblspace.executed_queries, 488 | options=tblspace.settings, 489 | location=tblspace.location, 490 | ) 491 | 492 | if state == 'present' and tblspace.new_name: 493 | kw['newname'] = tblspace.new_name 494 | 495 | module.exit_json(**kw) 496 | 497 | 498 | if __name__ == '__main__': 499 | main() 500 | -------------------------------------------------------------------------------- /playbooks/library/postgresql_idx.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright: (c) 2018-2019, Andrey Klychkov (@Andersson007) 5 | # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 | 7 | # Contribution: 8 | # Adaptation to pg8000 driver (C) Sergey Pechenko <10977752+tnt4brain@users.noreply.github.com>, 2021 9 | # Welcome to https://t.me/pro_ansible for discussion and support 10 | # License: please see above 11 | 12 | from __future__ import absolute_import, division, print_function 13 | __metaclass__ = type 14 | 15 | ANSIBLE_METADATA = { 16 | 'metadata_version': '1.1', 17 | 'status': ['preview'], 18 | 'supported_by': 'community' 19 | } 20 | 21 | DOCUMENTATION = r''' 22 | --- 23 | module: postgresql_idx 24 | short_description: Create or drop indexes from a PostgreSQL database 25 | description: 26 | - Create or drop indexes from a PostgreSQL database. 27 | version_added: '2.8' 28 | 29 | options: 30 | idxname: 31 | description: 32 | - Name of the index to create or drop. 33 | type: str 34 | required: true 35 | aliases: 36 | - name 37 | db: 38 | description: 39 | - Name of database to connect to and where the index will be created/dropped. 40 | type: str 41 | aliases: 42 | - login_db 43 | session_role: 44 | description: 45 | - Switch to session_role after connecting. 46 | The specified session_role must be a role that the current login_user is a member of. 47 | - Permissions checking for SQL commands is carried out as though 48 | the session_role were the one that had logged in originally. 49 | type: str 50 | schema: 51 | description: 52 | - Name of a database schema where the index will be created. 53 | type: str 54 | state: 55 | description: 56 | - Index state. 57 | - I(state=present) implies the index will be created if it does not exist. 58 | - I(state=absent) implies the index will be dropped if it exists. 59 | type: str 60 | default: present 61 | choices: [ absent, present ] 62 | table: 63 | description: 64 | - Table to create index on it. 65 | - Mutually exclusive with I(state=absent). 66 | type: str 67 | required: true 68 | columns: 69 | description: 70 | - List of index columns that need to be covered by index. 71 | - Mutually exclusive with I(state=absent). 72 | type: list 73 | elements: str 74 | aliases: 75 | - column 76 | cond: 77 | description: 78 | - Index conditions. 79 | - Mutually exclusive with I(state=absent). 80 | type: str 81 | idxtype: 82 | description: 83 | - Index type (like btree, gist, gin, etc.). 84 | - Mutually exclusive with I(state=absent). 85 | type: str 86 | aliases: 87 | - type 88 | concurrent: 89 | description: 90 | - Enable or disable concurrent mode (CREATE / DROP INDEX CONCURRENTLY). 91 | - Pay attention, if I(concurrent=no), the table will be locked (ACCESS EXCLUSIVE) during the building process. 92 | For more information about the lock levels see U(https://www.postgresql.org/docs/current/explicit-locking.html). 93 | - If the building process was interrupted for any reason when I(cuncurrent=yes), the index becomes invalid. 94 | In this case it should be dropped and created again. 95 | - Mutually exclusive with I(cascade=yes). 96 | type: bool 97 | default: yes 98 | tablespace: 99 | description: 100 | - Set a tablespace for the index. 101 | - Mutually exclusive with I(state=absent). 102 | required: false 103 | type: str 104 | storage_params: 105 | description: 106 | - Storage parameters like fillfactor, vacuum_cleanup_index_scale_factor, etc. 107 | - Mutually exclusive with I(state=absent). 108 | type: list 109 | elements: str 110 | cascade: 111 | description: 112 | - Automatically drop objects that depend on the index, 113 | and in turn all objects that depend on those objects. 114 | - It used only with I(state=absent). 115 | - Mutually exclusive with I(concurrent=yes) 116 | type: bool 117 | default: no 118 | 119 | seealso: 120 | - module: postgresql_table 121 | - module: postgresql_tablespace 122 | - name: PostgreSQL indexes reference 123 | description: General information about PostgreSQL indexes. 124 | link: https://www.postgresql.org/docs/current/indexes.html 125 | - name: CREATE INDEX reference 126 | description: Complete reference of the CREATE INDEX command documentation. 127 | link: https://www.postgresql.org/docs/current/sql-createindex.html 128 | - name: ALTER INDEX reference 129 | description: Complete reference of the ALTER INDEX command documentation. 130 | link: https://www.postgresql.org/docs/current/sql-alterindex.html 131 | - name: DROP INDEX reference 132 | description: Complete reference of the DROP INDEX command documentation. 133 | link: https://www.postgresql.org/docs/current/sql-dropindex.html 134 | 135 | notes: 136 | - The index building process can affect database performance. 137 | - To avoid table locks on production databases, use I(concurrent=yes) (default behavior). 138 | 139 | author: 140 | - Andrew Klychkov (@Andersson007) 141 | 142 | extends_documentation_fragment: postgres 143 | ''' 144 | 145 | EXAMPLES = r''' 146 | - name: Create btree index if not exists test_idx concurrently covering columns id and name of table products 147 | postgresql_idx: 148 | db: acme 149 | table: products 150 | columns: id,name 151 | name: test_idx 152 | 153 | - name: Create btree index test_idx concurrently with tablespace called ssd and storage parameter 154 | postgresql_idx: 155 | db: acme 156 | table: products 157 | columns: 158 | - id 159 | - name 160 | idxname: test_idx 161 | tablespace: ssd 162 | storage_params: 163 | - fillfactor=90 164 | 165 | - name: Create gist index test_gist_idx concurrently on column geo_data of table map 166 | postgresql_idx: 167 | db: somedb 168 | table: map 169 | idxtype: gist 170 | columns: geo_data 171 | idxname: test_gist_idx 172 | 173 | # Note: for the example below pg_trgm extension must be installed for gin_trgm_ops 174 | - name: Create gin index gin0_idx not concurrently on column comment of table test 175 | postgresql_idx: 176 | idxname: gin0_idx 177 | table: test 178 | columns: comment gin_trgm_ops 179 | concurrent: no 180 | idxtype: gin 181 | 182 | - name: Drop btree test_idx concurrently 183 | postgresql_idx: 184 | db: mydb 185 | idxname: test_idx 186 | state: absent 187 | 188 | - name: Drop test_idx cascade 189 | postgresql_idx: 190 | db: mydb 191 | idxname: test_idx 192 | state: absent 193 | cascade: yes 194 | concurrent: no 195 | 196 | - name: Create btree index test_idx concurrently on columns id,comment where column id > 1 197 | postgresql_idx: 198 | db: mydb 199 | table: test 200 | columns: id,comment 201 | idxname: test_idx 202 | cond: id > 1 203 | ''' 204 | 205 | RETURN = r''' 206 | name: 207 | description: Index name. 208 | returned: always 209 | type: str 210 | sample: 'foo_idx' 211 | state: 212 | description: Index state. 213 | returned: always 214 | type: str 215 | sample: 'present' 216 | schema: 217 | description: Schema where index exists. 218 | returned: always 219 | type: str 220 | sample: 'public' 221 | tablespace: 222 | description: Tablespace where index exists. 223 | returned: always 224 | type: str 225 | sample: 'ssd' 226 | query: 227 | description: Query that was tried to be executed. 228 | returned: always 229 | type: str 230 | sample: 'CREATE INDEX CONCURRENTLY foo_idx ON test_table USING BTREE (id)' 231 | storage_params: 232 | description: Index storage parameters. 233 | returned: always 234 | type: list 235 | sample: [ "fillfactor=90" ] 236 | valid: 237 | description: Index validity. 238 | returned: always 239 | type: bool 240 | sample: true 241 | ''' 242 | 243 | 244 | from ansible.module_utils.basic import AnsibleModule 245 | from ansible.module_utils.postgres import ( 246 | connect_to_db, 247 | exec_sql, 248 | get_conn_params, 249 | postgres_common_argument_spec, 250 | ) 251 | 252 | 253 | VALID_IDX_TYPES = ('BTREE', 'HASH', 'GIST', 'SPGIST', 'GIN', 'BRIN') 254 | 255 | 256 | # =========================================== 257 | # PostgreSQL module specific support methods. 258 | # 259 | 260 | class Index(object): 261 | 262 | """Class for working with PostgreSQL indexes. 263 | 264 | TODO: 265 | 1. Add possibility to change ownership 266 | 2. Add possibility to change tablespace 267 | 3. Add list called executed_queries (executed_query should be left too) 268 | 4. Use self.module instead of passing arguments to the methods whenever possible 269 | 270 | Args: 271 | module (AnsibleModule) -- object of AnsibleModule class 272 | cursor (cursor) -- cursor object of psycopg2 library 273 | schema (str) -- name of the index schema 274 | name (str) -- name of the index 275 | 276 | Attrs: 277 | module (AnsibleModule) -- object of AnsibleModule class 278 | cursor (cursor) -- cursor object of psycopg2 library 279 | schema (str) -- name of the index schema 280 | name (str) -- name of the index 281 | exists (bool) -- flag the index exists in the DB or not 282 | info (dict) -- dict that contents information about the index 283 | executed_query (str) -- executed query 284 | """ 285 | 286 | def __init__(self, module, cursor, schema, name): 287 | self.name = name 288 | if schema: 289 | self.schema = schema 290 | else: 291 | self.schema = 'public' 292 | self.module = module 293 | self.cursor = cursor 294 | self.info = { 295 | 'name': self.name, 296 | 'state': 'absent', 297 | 'schema': '', 298 | 'tblname': '', 299 | 'tblspace': '', 300 | 'valid': True, 301 | 'storage_params': [], 302 | } 303 | self.exists = False 304 | self.__exists_in_db() 305 | self.executed_query = '' 306 | 307 | def get_info(self): 308 | """Refresh index info. 309 | 310 | Return self.info dict. 311 | """ 312 | self.__exists_in_db() 313 | return self.info 314 | 315 | def __exists_in_db(self): 316 | """Check index existence, collect info, add it to self.info dict. 317 | 318 | Return True if the index exists, otherwise, return False. 319 | """ 320 | query = ("SELECT i.schemaname, i.tablename, i.tablespace, " 321 | "pi.indisvalid, c.reloptions " 322 | "FROM pg_catalog.pg_indexes AS i " 323 | "JOIN pg_catalog.pg_class AS c " 324 | "ON i.indexname = c.relname " 325 | "JOIN pg_catalog.pg_index AS pi " 326 | "ON c.oid = pi.indexrelid " 327 | "WHERE i.indexname = %s") 328 | 329 | res = exec_sql(self, query, query_params=[self.name], add_to_executed=False) 330 | if res: 331 | self.exists = True 332 | self.info = dict( 333 | name=self.name, 334 | state='present', 335 | schema=res[0][0], 336 | tblname=res[0][1], 337 | tblspace=res[0][2] if res[0][2] else '', 338 | valid=res[0][3], 339 | storage_params=res[0][4] if res[0][4] else [], 340 | ) 341 | return True 342 | 343 | else: 344 | self.exists = False 345 | return False 346 | 347 | def create(self, tblname, idxtype, columns, cond, tblspace, storage_params, concurrent=True): 348 | """Create PostgreSQL index. 349 | 350 | Return True if success, otherwise, return False. 351 | 352 | Args: 353 | tblname (str) -- name of a table for the index 354 | idxtype (str) -- type of the index like BTREE, BRIN, etc 355 | columns (str) -- string of comma-separated columns that need to be covered by index 356 | tblspace (str) -- tablespace for storing the index 357 | storage_params (str) -- string of comma-separated storage parameters 358 | 359 | Kwargs: 360 | concurrent (bool) -- build index in concurrent mode, default True 361 | """ 362 | if self.exists: 363 | return False 364 | 365 | changed = False 366 | if idxtype is None: 367 | idxtype = "BTREE" 368 | 369 | query = 'CREATE INDEX' 370 | 371 | if concurrent: 372 | query += ' CONCURRENTLY' 373 | 374 | query += ' %s' % self.name 375 | 376 | if self.schema: 377 | query += ' ON %s.%s ' % (self.schema, tblname) 378 | else: 379 | query += 'public.%s ' % tblname 380 | 381 | query += 'USING %s (%s)' % (idxtype, columns) 382 | 383 | if storage_params: 384 | query += ' WITH (%s)' % storage_params 385 | 386 | if tblspace: 387 | query += ' TABLESPACE %s' % tblspace 388 | 389 | if cond: 390 | query += ' WHERE %s' % cond 391 | 392 | self.executed_query = query 393 | 394 | if exec_sql(self, query, ddl=True, add_to_executed=False): 395 | return True 396 | 397 | return False 398 | 399 | def drop(self, schema, cascade=False, concurrent=True): 400 | """Drop PostgreSQL index. 401 | 402 | Return True if success, otherwise, return False. 403 | 404 | Args: 405 | schema (str) -- name of the index schema 406 | 407 | Kwargs: 408 | cascade (bool) -- automatically drop objects that depend on the index, 409 | default False 410 | concurrent (bool) -- build index in concurrent mode, default True 411 | """ 412 | changed = False 413 | if not self.exists: 414 | return False 415 | 416 | query = 'DROP INDEX' 417 | 418 | if concurrent: 419 | query += ' CONCURRENTLY' 420 | 421 | if not schema: 422 | query += ' public.%s' % self.name 423 | else: 424 | query += ' %s.%s' % (schema, self.name) 425 | 426 | if cascade: 427 | query += ' CASCADE' 428 | 429 | self.executed_query = query 430 | 431 | if exec_sql(self, query, ddl=True, add_to_executed=False): 432 | return True 433 | 434 | return False 435 | 436 | 437 | # =========================================== 438 | # Module execution. 439 | # 440 | 441 | 442 | def main(): 443 | argument_spec = postgres_common_argument_spec() 444 | argument_spec.update( 445 | idxname=dict(type='str', required=True, aliases=['name']), 446 | db=dict(type='str', aliases=['login_db']), 447 | state=dict(type='str', default='present', choices=['absent', 'present']), 448 | concurrent=dict(type='bool', default=True), 449 | table=dict(type='str'), 450 | idxtype=dict(type='str', aliases=['type']), 451 | columns=dict(type='list', aliases=['column']), 452 | cond=dict(type='str'), 453 | session_role=dict(type='str'), 454 | tablespace=dict(type='str'), 455 | storage_params=dict(type='list'), 456 | cascade=dict(type='bool', default=False), 457 | schema=dict(type='str'), 458 | ) 459 | module = AnsibleModule( 460 | argument_spec=argument_spec, 461 | supports_check_mode=True, 462 | ) 463 | 464 | idxname = module.params["idxname"] 465 | state = module.params["state"] 466 | concurrent = module.params["concurrent"] 467 | table = module.params["table"] 468 | idxtype = module.params["idxtype"] 469 | columns = module.params["columns"] 470 | cond = module.params["cond"] 471 | tablespace = module.params["tablespace"] 472 | storage_params = module.params["storage_params"] 473 | cascade = module.params["cascade"] 474 | schema = module.params["schema"] 475 | 476 | if concurrent and cascade: 477 | module.fail_json(msg="Cuncurrent mode and cascade parameters are mutually exclusive") 478 | 479 | if state == 'present': 480 | if not table: 481 | module.fail_json(msg="Table must be specified") 482 | if not columns: 483 | module.fail_json(msg="At least one column must be specified") 484 | else: 485 | if table or columns or cond or idxtype or tablespace: 486 | module.fail_json(msg="Index %s is going to be removed, so it does not " 487 | "make sense to pass a table name, columns, conditions, " 488 | "index type, or tablespace" % idxname) 489 | 490 | if cascade and state != 'absent': 491 | module.fail_json(msg="cascade parameter used only with state=absent") 492 | 493 | conn_params = get_conn_params(module, module.params) 494 | db_connection = connect_to_db(module, conn_params, autocommit=True) 495 | cursor = db_connection.cursor() 496 | 497 | # Set defaults: 498 | changed = False 499 | 500 | # Do job: 501 | index = Index(module, cursor, schema, idxname) 502 | kw = index.get_info() 503 | kw['query'] = '' 504 | 505 | # 506 | # check_mode start 507 | if module.check_mode: 508 | if state == 'present' and index.exists: 509 | kw['changed'] = False 510 | module.exit_json(**kw) 511 | 512 | elif state == 'present' and not index.exists: 513 | kw['changed'] = True 514 | module.exit_json(**kw) 515 | 516 | elif state == 'absent' and not index.exists: 517 | kw['changed'] = False 518 | module.exit_json(**kw) 519 | 520 | elif state == 'absent' and index.exists: 521 | kw['changed'] = True 522 | module.exit_json(**kw) 523 | # check_mode end 524 | # 525 | 526 | if state == "present": 527 | if idxtype and idxtype.upper() not in VALID_IDX_TYPES: 528 | module.fail_json(msg="Index type '%s' of %s is not in valid types" % (idxtype, idxname)) 529 | 530 | columns = ','.join(columns) 531 | 532 | if storage_params: 533 | storage_params = ','.join(storage_params) 534 | 535 | changed = index.create(table, idxtype, columns, cond, tablespace, storage_params, concurrent) 536 | 537 | if changed: 538 | kw = index.get_info() 539 | kw['state'] = 'present' 540 | kw['query'] = index.executed_query 541 | 542 | else: 543 | changed = index.drop(schema, cascade, concurrent) 544 | 545 | if changed: 546 | kw['state'] = 'absent' 547 | kw['query'] = index.executed_query 548 | 549 | if not kw['valid']: 550 | db_connection.rollback() 551 | module.warn("Index %s is invalid! ROLLBACK" % idxname) 552 | 553 | if not concurrent: 554 | db_connection.commit() 555 | 556 | kw['changed'] = changed 557 | db_connection.close() 558 | module.exit_json(**kw) 559 | 560 | 561 | if __name__ == '__main__': 562 | main() 563 | -------------------------------------------------------------------------------- /playbooks/module_utils/scramp/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (built by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. Generated by 9 | # versioneer-0.18 (https://github.com/warner/python-versioneer) 10 | 11 | """Git implementation of _version.py.""" 12 | 13 | import errno 14 | import os 15 | import re 16 | import subprocess 17 | import sys 18 | 19 | 20 | def get_keywords(): 21 | """Get the keywords needed to look up the version information.""" 22 | # these strings will be replaced by git during git-archive. 23 | # setup.py/versioneer.py will grep for the variable names, so they must 24 | # each be defined on a line of their own. _version.py will just call 25 | # get_keywords(). 26 | git_refnames = " (tag: 1.4.0)" 27 | git_full = "6fd6ae54b6ca911b6d2742149227c2fa13f9f67a" 28 | git_date = "2021-03-28 19:08:43 +0100" 29 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 30 | return keywords 31 | 32 | 33 | class VersioneerConfig: 34 | """Container for Versioneer configuration parameters.""" 35 | 36 | 37 | def get_config(): 38 | """Create, populate and return the VersioneerConfig() object.""" 39 | # these strings are filled in when 'setup.py versioneer' creates 40 | # _version.py 41 | cfg = VersioneerConfig() 42 | cfg.VCS = "git" 43 | cfg.style = "pep440" 44 | cfg.tag_prefix = "" 45 | cfg.parentdir_prefix = "scramp-" 46 | cfg.versionfile_source = "scramp/_version.py" 47 | cfg.verbose = False 48 | return cfg 49 | 50 | 51 | class NotThisMethod(Exception): 52 | """Exception raised if a method is not valid for the current scenario.""" 53 | 54 | 55 | LONG_VERSION_PY = {} 56 | HANDLERS = {} 57 | 58 | 59 | def register_vcs_handler(vcs, method): # decorator 60 | """Decorator to mark a method as the handler for a particular VCS.""" 61 | def decorate(f): 62 | """Store f in HANDLERS[vcs][method].""" 63 | if vcs not in HANDLERS: 64 | HANDLERS[vcs] = {} 65 | HANDLERS[vcs][method] = f 66 | return f 67 | return decorate 68 | 69 | 70 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 71 | env=None): 72 | """Call the given command(s).""" 73 | assert isinstance(commands, list) 74 | p = None 75 | for c in commands: 76 | try: 77 | dispcmd = str([c] + args) 78 | # remember shell=False, so use git.cmd on windows, not just git 79 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 80 | stdout=subprocess.PIPE, 81 | stderr=(subprocess.PIPE if hide_stderr 82 | else None)) 83 | break 84 | except EnvironmentError: 85 | e = sys.exc_info()[1] 86 | if e.errno == errno.ENOENT: 87 | continue 88 | if verbose: 89 | print("unable to run %s" % dispcmd) 90 | print(e) 91 | return None, None 92 | else: 93 | if verbose: 94 | print("unable to find command, tried %s" % (commands,)) 95 | return None, None 96 | stdout = p.communicate()[0].strip() 97 | if sys.version_info[0] >= 3: 98 | stdout = stdout.decode() 99 | if p.returncode != 0: 100 | if verbose: 101 | print("unable to run %s (error)" % dispcmd) 102 | print("stdout was %s" % stdout) 103 | return None, p.returncode 104 | return stdout, p.returncode 105 | 106 | 107 | def versions_from_parentdir(parentdir_prefix, root, verbose): 108 | """Try to determine the version from the parent directory name. 109 | 110 | Source tarballs conventionally unpack into a directory that includes both 111 | the project name and a version string. We will also support searching up 112 | two directory levels for an appropriately named parent directory 113 | """ 114 | rootdirs = [] 115 | 116 | for i in range(3): 117 | dirname = os.path.basename(root) 118 | if dirname.startswith(parentdir_prefix): 119 | return {"version": dirname[len(parentdir_prefix):], 120 | "full-revisionid": None, 121 | "dirty": False, "error": None, "date": None} 122 | else: 123 | rootdirs.append(root) 124 | root = os.path.dirname(root) # up a level 125 | 126 | if verbose: 127 | print("Tried directories %s but none started with prefix %s" % 128 | (str(rootdirs), parentdir_prefix)) 129 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 130 | 131 | 132 | @register_vcs_handler("git", "get_keywords") 133 | def git_get_keywords(versionfile_abs): 134 | """Extract version information from the given file.""" 135 | # the code embedded in _version.py can just fetch the value of these 136 | # keywords. When used from setup.py, we don't want to import _version.py, 137 | # so we do it with a regexp instead. This function is not used from 138 | # _version.py. 139 | keywords = {} 140 | try: 141 | f = open(versionfile_abs, "r") 142 | for line in f.readlines(): 143 | if line.strip().startswith("git_refnames ="): 144 | mo = re.search(r'=\s*"(.*)"', line) 145 | if mo: 146 | keywords["refnames"] = mo.group(1) 147 | if line.strip().startswith("git_full ="): 148 | mo = re.search(r'=\s*"(.*)"', line) 149 | if mo: 150 | keywords["full"] = mo.group(1) 151 | if line.strip().startswith("git_date ="): 152 | mo = re.search(r'=\s*"(.*)"', line) 153 | if mo: 154 | keywords["date"] = mo.group(1) 155 | f.close() 156 | except EnvironmentError: 157 | pass 158 | return keywords 159 | 160 | 161 | @register_vcs_handler("git", "keywords") 162 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 163 | """Get version information from git keywords.""" 164 | if not keywords: 165 | raise NotThisMethod("no keywords at all, weird") 166 | date = keywords.get("date") 167 | if date is not None: 168 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 169 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 170 | # -like" string, which we must then edit to make compliant), because 171 | # it's been around since git-1.5.3, and it's too difficult to 172 | # discover which version we're using, or to work around using an 173 | # older one. 174 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 175 | refnames = keywords["refnames"].strip() 176 | if refnames.startswith("$Format"): 177 | if verbose: 178 | print("keywords are unexpanded, not using") 179 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 180 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 181 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 182 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 183 | TAG = "tag: " 184 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 185 | if not tags: 186 | # Either we're using git < 1.8.3, or there really are no tags. We use 187 | # a heuristic: assume all version tags have a digit. The old git %d 188 | # expansion behaves like git log --decorate=short and strips out the 189 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 190 | # between branches and tags. By ignoring refnames without digits, we 191 | # filter out many common branch names like "release" and 192 | # "stabilization", as well as "HEAD" and "master". 193 | tags = set([r for r in refs if re.search(r'\d', r)]) 194 | if verbose: 195 | print("discarding '%s', no digits" % ",".join(refs - tags)) 196 | if verbose: 197 | print("likely tags: %s" % ",".join(sorted(tags))) 198 | for ref in sorted(tags): 199 | # sorting will prefer e.g. "2.0" over "2.0rc1" 200 | if ref.startswith(tag_prefix): 201 | r = ref[len(tag_prefix):] 202 | if verbose: 203 | print("picking %s" % r) 204 | return {"version": r, 205 | "full-revisionid": keywords["full"].strip(), 206 | "dirty": False, "error": None, 207 | "date": date} 208 | # no suitable tags, so version is "0+unknown", but full hex is still there 209 | if verbose: 210 | print("no suitable tags, using unknown + full revision id") 211 | return {"version": "0+unknown", 212 | "full-revisionid": keywords["full"].strip(), 213 | "dirty": False, "error": "no suitable tags", "date": None} 214 | 215 | 216 | @register_vcs_handler("git", "pieces_from_vcs") 217 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 218 | """Get version from 'git describe' in the root of the source tree. 219 | 220 | This only gets called if the git-archive 'subst' keywords were *not* 221 | expanded, and _version.py hasn't already been rewritten with a short 222 | version string, meaning we're inside a checked out source tree. 223 | """ 224 | GITS = ["git"] 225 | if sys.platform == "win32": 226 | GITS = ["git.cmd", "git.exe"] 227 | 228 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 229 | hide_stderr=True) 230 | if rc != 0: 231 | if verbose: 232 | print("Directory %s not under git control" % root) 233 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 234 | 235 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 236 | # if there isn't one, this yields HEX[-dirty] (no NUM) 237 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 238 | "--always", "--long", 239 | "--match", "%s*" % tag_prefix], 240 | cwd=root) 241 | # --long was added in git-1.5.5 242 | if describe_out is None: 243 | raise NotThisMethod("'git describe' failed") 244 | describe_out = describe_out.strip() 245 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 246 | if full_out is None: 247 | raise NotThisMethod("'git rev-parse' failed") 248 | full_out = full_out.strip() 249 | 250 | pieces = {} 251 | pieces["long"] = full_out 252 | pieces["short"] = full_out[:7] # maybe improved later 253 | pieces["error"] = None 254 | 255 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 256 | # TAG might have hyphens. 257 | git_describe = describe_out 258 | 259 | # look for -dirty suffix 260 | dirty = git_describe.endswith("-dirty") 261 | pieces["dirty"] = dirty 262 | if dirty: 263 | git_describe = git_describe[:git_describe.rindex("-dirty")] 264 | 265 | # now we have TAG-NUM-gHEX or HEX 266 | 267 | if "-" in git_describe: 268 | # TAG-NUM-gHEX 269 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 270 | if not mo: 271 | # unparseable. Maybe git-describe is misbehaving? 272 | pieces["error"] = ("unable to parse git-describe output: '%s'" 273 | % describe_out) 274 | return pieces 275 | 276 | # tag 277 | full_tag = mo.group(1) 278 | if not full_tag.startswith(tag_prefix): 279 | if verbose: 280 | fmt = "tag '%s' doesn't start with prefix '%s'" 281 | print(fmt % (full_tag, tag_prefix)) 282 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 283 | % (full_tag, tag_prefix)) 284 | return pieces 285 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 286 | 287 | # distance: number of commits since tag 288 | pieces["distance"] = int(mo.group(2)) 289 | 290 | # commit: short hex revision ID 291 | pieces["short"] = mo.group(3) 292 | 293 | else: 294 | # HEX: no tags 295 | pieces["closest-tag"] = None 296 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 297 | cwd=root) 298 | pieces["distance"] = int(count_out) # total number of commits 299 | 300 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 301 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], 302 | cwd=root)[0].strip() 303 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 304 | 305 | return pieces 306 | 307 | 308 | def plus_or_dot(pieces): 309 | """Return a + if we don't already have one, else return a .""" 310 | if "+" in pieces.get("closest-tag", ""): 311 | return "." 312 | return "+" 313 | 314 | 315 | def render_pep440(pieces): 316 | """Build up version string, with post-release "local version identifier". 317 | 318 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 319 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 320 | 321 | Exceptions: 322 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 323 | """ 324 | if pieces["closest-tag"]: 325 | rendered = pieces["closest-tag"] 326 | if pieces["distance"] or pieces["dirty"]: 327 | rendered += plus_or_dot(pieces) 328 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 329 | if pieces["dirty"]: 330 | rendered += ".dirty" 331 | else: 332 | # exception #1 333 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 334 | pieces["short"]) 335 | if pieces["dirty"]: 336 | rendered += ".dirty" 337 | return rendered 338 | 339 | 340 | def render_pep440_pre(pieces): 341 | """TAG[.post.devDISTANCE] -- No -dirty. 342 | 343 | Exceptions: 344 | 1: no tags. 0.post.devDISTANCE 345 | """ 346 | if pieces["closest-tag"]: 347 | rendered = pieces["closest-tag"] 348 | if pieces["distance"]: 349 | rendered += ".post.dev%d" % pieces["distance"] 350 | else: 351 | # exception #1 352 | rendered = "0.post.dev%d" % pieces["distance"] 353 | return rendered 354 | 355 | 356 | def render_pep440_post(pieces): 357 | """TAG[.postDISTANCE[.dev0]+gHEX] . 358 | 359 | The ".dev0" means dirty. Note that .dev0 sorts backwards 360 | (a dirty tree will appear "older" than the corresponding clean one), 361 | but you shouldn't be releasing software with -dirty anyways. 362 | 363 | Exceptions: 364 | 1: no tags. 0.postDISTANCE[.dev0] 365 | """ 366 | if pieces["closest-tag"]: 367 | rendered = pieces["closest-tag"] 368 | if pieces["distance"] or pieces["dirty"]: 369 | rendered += ".post%d" % pieces["distance"] 370 | if pieces["dirty"]: 371 | rendered += ".dev0" 372 | rendered += plus_or_dot(pieces) 373 | rendered += "g%s" % pieces["short"] 374 | else: 375 | # exception #1 376 | rendered = "0.post%d" % pieces["distance"] 377 | if pieces["dirty"]: 378 | rendered += ".dev0" 379 | rendered += "+g%s" % pieces["short"] 380 | return rendered 381 | 382 | 383 | def render_pep440_old(pieces): 384 | """TAG[.postDISTANCE[.dev0]] . 385 | 386 | The ".dev0" means dirty. 387 | 388 | Eexceptions: 389 | 1: no tags. 0.postDISTANCE[.dev0] 390 | """ 391 | if pieces["closest-tag"]: 392 | rendered = pieces["closest-tag"] 393 | if pieces["distance"] or pieces["dirty"]: 394 | rendered += ".post%d" % pieces["distance"] 395 | if pieces["dirty"]: 396 | rendered += ".dev0" 397 | else: 398 | # exception #1 399 | rendered = "0.post%d" % pieces["distance"] 400 | if pieces["dirty"]: 401 | rendered += ".dev0" 402 | return rendered 403 | 404 | 405 | def render_git_describe(pieces): 406 | """TAG[-DISTANCE-gHEX][-dirty]. 407 | 408 | Like 'git describe --tags --dirty --always'. 409 | 410 | Exceptions: 411 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 412 | """ 413 | if pieces["closest-tag"]: 414 | rendered = pieces["closest-tag"] 415 | if pieces["distance"]: 416 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 417 | else: 418 | # exception #1 419 | rendered = pieces["short"] 420 | if pieces["dirty"]: 421 | rendered += "-dirty" 422 | return rendered 423 | 424 | 425 | def render_git_describe_long(pieces): 426 | """TAG-DISTANCE-gHEX[-dirty]. 427 | 428 | Like 'git describe --tags --dirty --always -long'. 429 | The distance/hash is unconditional. 430 | 431 | Exceptions: 432 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 433 | """ 434 | if pieces["closest-tag"]: 435 | rendered = pieces["closest-tag"] 436 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 437 | else: 438 | # exception #1 439 | rendered = pieces["short"] 440 | if pieces["dirty"]: 441 | rendered += "-dirty" 442 | return rendered 443 | 444 | 445 | def render(pieces, style): 446 | """Render the given version pieces into the requested style.""" 447 | if pieces["error"]: 448 | return {"version": "unknown", 449 | "full-revisionid": pieces.get("long"), 450 | "dirty": None, 451 | "error": pieces["error"], 452 | "date": None} 453 | 454 | if not style or style == "default": 455 | style = "pep440" # the default 456 | 457 | if style == "pep440": 458 | rendered = render_pep440(pieces) 459 | elif style == "pep440-pre": 460 | rendered = render_pep440_pre(pieces) 461 | elif style == "pep440-post": 462 | rendered = render_pep440_post(pieces) 463 | elif style == "pep440-old": 464 | rendered = render_pep440_old(pieces) 465 | elif style == "git-describe": 466 | rendered = render_git_describe(pieces) 467 | elif style == "git-describe-long": 468 | rendered = render_git_describe_long(pieces) 469 | else: 470 | raise ValueError("unknown style '%s'" % style) 471 | 472 | return {"version": rendered, "full-revisionid": pieces["long"], 473 | "dirty": pieces["dirty"], "error": None, 474 | "date": pieces.get("date")} 475 | 476 | 477 | def get_versions(): 478 | """Get version information or return default if unable to do so.""" 479 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 480 | # __file__, we can work backwards from there to the root. Some 481 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 482 | # case we can only use expanded keywords. 483 | 484 | cfg = get_config() 485 | verbose = cfg.verbose 486 | 487 | try: 488 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 489 | verbose) 490 | except NotThisMethod: 491 | pass 492 | 493 | try: 494 | root = os.path.realpath(__file__) 495 | # versionfile_source is the relative path from the top of the source 496 | # tree (where the .git directory might live) to this file. Invert 497 | # this to find the root from __file__. 498 | for i in cfg.versionfile_source.split('/'): 499 | root = os.path.dirname(root) 500 | except NameError: 501 | return {"version": "0+unknown", "full-revisionid": None, 502 | "dirty": None, 503 | "error": "unable to find root of source tree", 504 | "date": None} 505 | 506 | try: 507 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 508 | return render(pieces, cfg.style) 509 | except NotThisMethod: 510 | pass 511 | 512 | try: 513 | if cfg.parentdir_prefix: 514 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 515 | except NotThisMethod: 516 | pass 517 | 518 | return {"version": "0+unknown", "full-revisionid": None, 519 | "dirty": None, 520 | "error": "unable to compute version", "date": None} 521 | --------------------------------------------------------------------------------