diff --git a/.rubocop.yml b/.rubocop.yml index 33688a79e..200675437 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -40,6 +40,10 @@ Style/BlockDelimiters: Description: Prefer braces for chaining. Mostly an aesthetical choice. Better to be consistent then. EnforcedStyle: braces_for_chaining +Style/BracesAroundHashParameters: + Description: Braces are required by Ruby 2.7. Cop removed from RuboCop v0.80.0. + See https://github.com/rubocop-hq/rubocop/pull/7643 + Enabled: true Style/ClassAndModuleChildren: Description: Compact style reduces the required amount of indentation. EnforcedStyle: compact diff --git a/.sync.yml b/.sync.yml index 868c80c04..0d4d91c3a 100644 --- a/.sync.yml +++ b/.sync.yml @@ -36,6 +36,7 @@ Gemfile: git: https://github.com/skywinder/github-changelog-generator ref: 20ee04ba1234e9e83eb2ffb5056e23d641c7a018 condition: Gem::Version.new(RUBY_VERSION.dup) >= Gem::Version.new('2.2.2') + - gem: puppet-resource_api Rakefile: requires: - puppet_pot_generator/rake_tasks diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 617778274..2f1e4f73a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,6 @@ { "recommendations": [ - "jpogran.puppet-vscode", + "puppet.puppet-vscode", "rebornix.Ruby" ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 87be362e0..ceb714c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org). -## [v10.4.0](https://github.com/puppetlabs/puppetlabs-mysql/tree/v10.3.0) (2020-02-27) +## [v10.5.0](https://github.com/puppetlabs/puppetlabs-mysql/tree/v10.5.0) (2020-05-13) -[Full Changelog](https://github.com/puppetlabs/puppetlabs-mysql/compare/v10.3.0...v10.3.0) +[Full Changelog](https://github.com/puppetlabs/puppetlabs-mysql/compare/v10.4.0...v10.5.0) + +### Added + +- Support mariadb's ed25519-based authentication [\#1292](https://github.com/puppetlabs/puppetlabs-mysql/pull/1292) ([dciabrin](https://github.com/dciabrin)) +- Allow changing the mysql-config-file group-ownership [\#1284](https://github.com/puppetlabs/puppetlabs-mysql/pull/1284) ([unki](https://github.com/unki)) + +### Fixed + +- Remove legacy \(old API\) `mysql\_password` function [\#1299](https://github.com/puppetlabs/puppetlabs-mysql/pull/1299) ([alexjfisher](https://github.com/alexjfisher)) +- Improve differences between generated mysql service id values [\#1293](https://github.com/puppetlabs/puppetlabs-mysql/pull/1293) ([ryaner](https://github.com/ryaner)) +- \(MODULES-10023\) Fix multiple xtrabackup regressions [\#1245](https://github.com/puppetlabs/puppetlabs-mysql/pull/1245) ([fraenki](https://github.com/fraenki)) +- Fix binarylog by allowing users to specify managed directories [\#1194](https://github.com/puppetlabs/puppetlabs-mysql/pull/1194) ([elfranne](https://github.com/elfranne)) + +## [v10.4.0](https://github.com/puppetlabs/puppetlabs-mysql/tree/v10.4.0) (2020-03-02) + +[Full Changelog](https://github.com/puppetlabs/puppetlabs-mysql/compare/v10.3.0...v10.4.0) ### Added - Allow adapting MySQL configuration file's permissions mode [\#1278](https://github.com/puppetlabs/puppetlabs-mysql/pull/1278) ([unki](https://github.com/unki)) - pdksync - \(FM-8581\) - Debian 10 added to travis and provision file refactored [\#1275](https://github.com/puppetlabs/puppetlabs-mysql/pull/1275) ([david22swan](https://github.com/david22swan)) -- Puppet 4 functions [\#1274](https://github.com/puppetlabs/puppetlabs-mysql/pull/1274) ([binford2k](https://github.com/binford2k)) - Allow backupcompress for xtrabackup profile [\#1196](https://github.com/puppetlabs/puppetlabs-mysql/pull/1196) ([Spuffnduff](https://github.com/Spuffnduff)) -- Enable module to not use default options [\#1192](https://github.com/puppetlabs/puppetlabs-mysql/pull/1192) ([mauricemeyer](https://github.com/mauricemeyer)) +- Enable module to not use default options [\#1192](https://github.com/puppetlabs/puppetlabs-mysql/pull/1192) ([morremeyer](https://github.com/morremeyer)) ## [v10.3.0](https://github.com/puppetlabs/puppetlabs-mysql/tree/v10.3.0) (2019-12-11) diff --git a/Gemfile b/Gemfile index eb8fb9455..37104315b 100644 --- a/Gemfile +++ b/Gemfile @@ -30,6 +30,9 @@ group :development do gem "puppet-module-win-dev-r#{minor_version}", '~> 0.4', require: false, platforms: [:mswin, :mingw, :x64_mingw] gem "puppet-lint-i18n", require: false gem "github_changelog_generator", require: false, git: 'https://github.com/skywinder/github-changelog-generator', ref: '20ee04ba1234e9e83eb2ffb5056e23d641c7a018' if Gem::Version.new(RUBY_VERSION.dup) >= Gem::Version.new('2.2.2') + gem 'ed25519', '>= 1.2', '< 2.0' + gem 'bcrypt_pbkdf', '>= 1.0', '< 2.0' + gem "puppet-resource_api", require: false end puppet_version = ENV['PUPPET_GEM_VERSION'] diff --git a/README.md b/README.md index a4cd7efd3..8d059bae6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ * [Install Percona server on CentOS](#install-percona-server-on-centos) * [Install MariaDB on Ubuntu](#install-mariadb-on-ubuntu) * [Install Plugins](#install-plugins) + * [Use Percona XtraBackup](#use-percona-xtrabackup) 4. [Reference - An under-the-hood peek at what the module is doing and how](REFERENCE.md) 5. [Limitations - OS compatibility, etc.](#limitations) 6. [Development - Guide for contributing to the module](#development) @@ -183,6 +184,36 @@ mysql::db { 'mydb': If required, the password can also be an empty string to allow connections without an password. +### Create login paths + +This feature works only for the MySQL Community Edition >= 5.6.6. + +A login path is a set of options (host, user, password, port and socket) that specify which MySQL server to connect to and which account to authenticate as. The authentication credentials and the other options are stored in an encrypted login file named .mylogin.cnf typically under the users home directory. + +More information about MySQL login paths: https://dev.mysql.com/doc/refman/8.0/en/mysql-config-editor.html. + +Some example for login paths: +```puppet +mysql_login_path { 'client': + owner => root, + host => 'localhost', + user => 'root', + password => Sensitive('secure'), + socket => '/var/run/mysqld/mysqld.sock', + ensure => present, +} + +mysql_login_path { 'remote_db': + owner => root, + host => '10.0.0.1', + user => 'network', + password => Sensitive('secure'), + port => 3306, + ensure => present, +} +``` +See examples/mysql_login_path.pp for further examples. + ### Install Percona server on CentOS This example shows how to do a minimal installation of a Percona server on a @@ -392,6 +423,69 @@ mysql::server::db: ### Install Plugins Plugins can be installed by using the `mysql_plugin` defined type. See `examples/mysql_plugin.pp` for futher examples. + +### Use Percona XtraBackup + +This example shows how to configure MySQL backups with Percona XtraBackup. This sets up a weekly cronjob to perform a full backup and additional daily cronjobs for incremental backups. Each backup will create a new directory. A cleanup job will automatically remove backups that are older than 15 days. + +```puppet +yumrepo { 'percona': + descr => 'CentOS $releasever - Percona', + baseurl => 'http://repo.percona.com/release/$releasever/RPMS/$basearch', + gpgkey => 'https://www.percona.com/downloads/RPM-GPG-KEY-percona https://repo.percona.com/yum/PERCONA-PACKAGING-KEY', + enabled => 1, + gpgcheck => 1, +} + +class { 'mysql::server::backup': + backupuser => 'myuser', + backuppassword => 'mypassword', + backupdir => '/tmp/backups', + provider => 'xtrabackup', + rotate => 15, + execpath => '/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin', + time => ['23', '15'], +} +``` + +If the daily or weekly backup was successful, then the empty file `/tmp/mysqlbackup_success` is created, which makes it easy to monitor the status of the database backup. + +After two weeks the backup directory should look similar to the example below. + +``` +/tmp/backups/2019-11-10_full +/tmp/backups/2019-11-11_23-15-01 +/tmp/backups/2019-11-13_23-15-01 +/tmp/backups/2019-11-13_23-15-02 +/tmp/backups/2019-11-14_23-15-01 +/tmp/backups/2019-11-15_23-15-02 +/tmp/backups/2019-11-16_23-15-01 +/tmp/backups/2019-11-17_full +/tmp/backups/2019-11-18_23-15-01 +/tmp/backups/2019-11-19_23-15-01 +/tmp/backups/2019-11-20_23-15-02 +/tmp/backups/2019-11-21_23-15-01 +/tmp/backups/2019-11-22_23-15-02 +/tmp/backups/2019-11-23_23-15-01 +``` + +A drawback of using incremental backups is the need to keep at least 7 days of backups, otherwise the full backups is removed early and consecutive incremental backups will fail. Furthermore an incremental backups becomes obsolete once the required full backup was removed. + +The next example uses XtraBackup with incremental backups disabled. In this case the daily cronjob will always perform a full backup. + +```puppet +class { 'mysql::server::backup': + backupuser => 'myuser', + backuppassword => 'mypassword', + backupdir => '/tmp/backups', + provider => 'xtrabackup', + incremental_backups => false, + rotate => 5, + execpath => '/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin', + time => ['23', '15'], +} +``` + ## Reference ### Classes @@ -549,3 +643,4 @@ This module is based on work by David Schmitt. The following contributors have c * Daniël van Eeden * Jan-Otto Kröpke * Timothy Sven Nelson +* Andreas Stürz diff --git a/REFERENCE.md b/REFERENCE.md index f2d0e3315..c99fa9b7b 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -29,10 +29,10 @@ _Private Classes_ * `mysql::client::install`: Private class for MySQL client install. * `mysql::params`: Params class. * `mysql::server::account_security`: Private class for ensuring localhost accounts do not exist -* `mysql::server::binarylog`: Binary log configuration requires the mysql user to be present. This must be done after package install * `mysql::server::config`: Private class for MySQL server configuration. * `mysql::server::install`: Private class for managing MySQL package. * `mysql::server::installdb`: Builds initial databases on installation. +* `mysql::server::managed_dirs`: Binary log configuration requires the mysql user to be present. This must be done after package install. * `mysql::server::providers`: Convenience class to call each of the three providers with the corresponding hashes provided in mysql::server. * `mysql::server::root_password`: Private class for managing the root password * `mysql::server::service`: Private class for managing the MySQL service @@ -46,6 +46,7 @@ _Private Classes_ _Public Resource types_ * [`mysql_grant`](#mysql_grant): @summary Manage a MySQL user's rights. +* [`mysql_login_path`](#mysql_login_path): Manage a MySQL login path. * [`mysql_plugin`](#mysql_plugin): Manage MySQL plugins. * [`mysql_user`](#mysql_user): @summary Manage a MySQL user. This includes management of users password as well as privileges. @@ -56,11 +57,24 @@ _Private Resource types_ **Functions** +* [`mysql::mysql_password`](#mysqlmysql_password): @summary * [`mysql::normalise_and_deepmerge`](#mysqlnormalise_and_deepmerge): Recursively merges two or more hashes together, normalises keys with differing use of dashesh and underscores, then returns the resulting hash. * [`mysql::password`](#mysqlpassword): Hash a string as mysql's "PASSWORD()" function would do it * [`mysql::strip_hash`](#mysqlstrip_hash): When given a hash this function strips out all blank entries. -* [`mysql_password`](#mysql_password): Hash a string as mysql's "PASSWORD()" function would do it +* [`mysql_password`](#mysql_password): DEPRECATED. Use the namespaced function [`mysql::password`](#mysqlpassword) instead. + +**Data types** + +* [`Mysql::Options`](#mysqloptions): + +**Data types** + +* [`Mysql::Options`](#mysqloptions): + +**Data types** + +* [`Mysql::Options`](#mysqloptions): **Tasks** @@ -411,6 +425,14 @@ The location, as a path, of the MySQL configuration file. Default value: $mysql::params::config_file +##### `config_file_mode` + +Data type: `Any` + +The MySQL configuration file's permissions mode. + +Default value: $mysql::params::config_file_mode + ##### `includedir` Data type: `Any` @@ -443,6 +465,14 @@ Whether the MySQL configuration file should be managed. Valid values are `true`, Default value: $mysql::params::manage_config_file +##### `options` + +Data type: `Mysql::Options` + +A hash of options structured like the override_options, but not merged with the default options. Use this if you don’t want your options merged with the default options. + +Default value: {} + ##### `override_options` Data type: `Any` @@ -515,6 +545,22 @@ The name of the group of the MySQL daemon user. Can be a group name or a group I Default value: $mysql::params::mysql_group +##### `mycnf_owner` + +Data type: `Any` + +Name or user-id who owns the mysql-config-file. + +Default value: $mysql::params::mycnf_owner + +##### `mycnf_group` + +Data type: `Any` + +Name or group-id which owns the mysql-config-file. + +Default value: $mysql::params::mycnf_group + ##### `root_password` Data type: `Any` @@ -715,7 +761,7 @@ Default value: $mysql::params::root_group Data type: `Any` -Whether or not to compress the backup (when using the mysqldump provider) +Whether or not to compress the backup (when using the mysqldump or xtrabackup provider) Default value: `true` @@ -791,6 +837,14 @@ Dump triggers for each dumped table when doing a `file_per_database` backup. Default value: `false` +##### `incremental_backups` + +Data type: `Any` + +A flag to activate/deactivate incremental backups. Currently only supported by the xtrabackup provider. + +Default value: `true` + ##### `ensure` Data type: `Any` @@ -1113,6 +1167,100 @@ namevar Name to describe the grant. +### mysql_login_path + +This type provides Puppet with the capabilities to store authentication credentials in an obfuscated login path file +named .mylogin.cnf created with the mysql_config_editor utility. Supports only MySQL Community Edition > v5.6.6. + +* **See also** +https://dev.mysql.com/doc/refman/8.0/en/mysql-config-editor.html + +#### Examples + +##### + +```puppet +mysql_login_path { 'local_socket': + owner => 'root', + host => 'localhost', + user => 'root', + password => Sensitive('secure'), + socket => '/var/run/mysql/mysql.sock', + ensure => present, +} + +mysql_login_path { 'local_tcp': + owner => 'root', + host => '127.0.0.1', + user => 'root', + password => Sensitive('more_secure'), + port => 3306, + ensure => present, +} +``` + +#### Properties + +The following properties are available in the `mysql_login_path` type. + +##### `ensure` + +Data type: `Enum[present, absent]` + +Whether this resource should be present or absent on the target system. + +##### `host` + +Data type: `Optional[String]` + +Host name to be entered into the login path. + +##### `user` + +Data type: `Optional[String]` + +Username to be entered into the login path. + +##### `password` + +Data type: `Optional[Sensitive[String[1]]]` + +Password to be entered into login path + +##### `socket` + +Data type: `Optional[String]` + +Socket path to be entered into login path + +##### `port` + +Data type: `Optional[Integer[0,65535]]` + +Port number to be entered into login path. + +#### Parameters + +The following parameters are available in the `mysql_login_path` type. + +##### `name` + +namevar + +Data type: `String` + +Name of the login path you want to manage. + +##### `owner` + +namevar + +Data type: `String` + +The user to whom the logon path should belong. + +Default value: root + ### mysql_plugin Manage MySQL plugins. @@ -1176,7 +1324,7 @@ Default value: present Valid values: %r{\w*} -The password hash of the user. Use mysql_password() for creating such a hash. +The password hash of the user. Use mysql::password() for creating such a hash. ##### `plugin` @@ -1224,6 +1372,37 @@ The name of the user. This uses the 'username@hostname' or username@hostname. ## Functions +### mysql::mysql_password + +Type: Ruby 4.x API + +---- original file header ---- + + Hash a string as mysql's "PASSWORD()" function would do it + + @param [String] password Plain text password. + + @return [String] the mysql password hash from the clear text password. + +#### `mysql::mysql_password(Any *$args)` + +---- original file header ---- + + Hash a string as mysql's "PASSWORD()" function would do it + + @param [String] password Plain text password. + + @return [String] the mysql password hash from the clear text password. + +Returns: `Data type` Describe what the function returns here + +##### `*args` + +Data type: `Any` + +The original array of arguments. Port this to individually managed params +to get the full benefit of the modern function API. + ### mysql::normalise_and_deepmerge Type: Ruby 4.x API @@ -1310,15 +1489,15 @@ Hash to be stripped ### mysql_password -Type: Ruby 3.x API +Type: Ruby 4.x API -Hash a string as mysql's "PASSWORD()" function would do it +DEPRECATED. Use the namespaced function [`mysql::password`](#mysqlpassword) instead. #### `mysql_password(String $password)` The mysql_password function. -Returns: `String` the mysql password hash from the clear text password. +Returns: `String` The mysql password hash from the 4.x function mysql::password. ##### `password` @@ -1326,6 +1505,14 @@ Data type: `String` Plain text password. +## Data types + +### Mysql::Options + +The Mysql::Options data type. + +Alias of `Hash[String, Hash]` + ## Tasks ### export diff --git a/examples/mysql_login_path.pp b/examples/mysql_login_path.pp new file mode 100644 index 000000000..228183da2 --- /dev/null +++ b/examples/mysql_login_path.pp @@ -0,0 +1,68 @@ +# Debian MySQL Commiunity Server 8.0 +include apt +apt::source { 'repo.mysql.com': + location => 'http://repo.mysql.com/apt/debian', + release => $::lsbdistcodename, + repos => 'mysql-8.0', + key => { + id => 'A4A9406876FCBD3C456770C88C718D3B5072E1F5', + server => 'hkp://keyserver.ubuntu.com:80', + }, + include => { + src => false, + deb => true, + }, + notify => Exec['apt-get update'] +} +exec { 'apt-get update': + path => '/usr/bin:/usr/sbin:/bin:/sbin', + refreshonly => true, +} + +$root_pw = 'password' +class { '::mysql::server': + root_password => $root_pw, + service_name => 'mysql', + package_name => 'mysql-community-server', + create_root_my_cnf => false, + require => [ + Apt::Source['repo.mysql.com'], + Exec['apt-get update'] + ], + notify => Mysql_login_path['client'] +} + +class { '::mysql::client': + package_manage => false, + package_name => 'mysql-community-client', + require => Class['::mysql::server'], +} + +mysql_login_path { 'client': + ensure => present, + host => 'localhost', + user => 'root', + password => Sensitive($root_pw), + socket => '/var/run/mysqld/mysqld.sock', + owner => root, +} + +mysql_login_path { 'local_dan': + ensure => present, + host => '127.0.0.1', + user => 'dan', + password => Sensitive('blah'), + port => 3306, + owner => root, + require => Class['::mysql::server'], +} + +mysql_user { 'dan@localhost': + ensure => present, + password_hash => mysql::password('blah'), + require => Mysql_login_path['client'], +} + + + + diff --git a/lib/puppet/functions/mysql/mysql_password.rb b/lib/puppet/functions/mysql/mysql_password.rb deleted file mode 100644 index 2f04b9898..000000000 --- a/lib/puppet/functions/mysql/mysql_password.rb +++ /dev/null @@ -1,44 +0,0 @@ -# This is an autogenerated function, ported from the original legacy version. -# It /should work/ as is, but will not have all the benefits of the modern -# function API. You should see the function docs to learn how to add function -# signatures for type safety and to document this function using puppet-strings. -# -# https://puppet.com/docs/puppet/latest/custom_functions_ruby.html -# -# ---- original file header ---- -require 'digest/sha1' -# ---- original file header ---- -# -# @summary -# @summary -# Hash a string as mysql's "PASSWORD()" function would do it -# -# @param [String] password Plain text password. -# -# @return [String] the mysql password hash from the clear text password. -# -# -Puppet::Functions.create_function(:'mysql::mysql_password') do - # @param args - # The original array of arguments. Port this to individually managed params - # to get the full benefit of the modern function API. - # - # @return [Data type] - # Describe what the function returns here - # - dispatch :default_impl do - # Call the method named 'default_impl' when this is matched - # Port this to match individual params for better type safety - repeated_param 'Any', :args - end - - def default_impl(*args) - if args.size != 1 - raise Puppet::ParseError, _('mysql_password(): Wrong number of arguments given (%{args_length} for 1)') % { args_length: args.length } - end - - return '' if args[0].empty? - return args[0] if args[0] =~ %r{\*[A-F0-9]{40}$} - '*' + Digest::SHA1.hexdigest(Digest::SHA1.digest(args[0])).upcase - end -end diff --git a/lib/puppet/functions/mysql_password.rb b/lib/puppet/functions/mysql_password.rb new file mode 100644 index 000000000..d2ac76d38 --- /dev/null +++ b/lib/puppet/functions/mysql_password.rb @@ -0,0 +1,17 @@ +# @summary DEPRECATED. Use the namespaced function [`mysql::password`](#mysqlpassword) instead. +Puppet::Functions.create_function(:mysql_password) do + # @param password + # Plain text password. + # + # @return + # The mysql password hash from the 4.x function mysql::password. + dispatch :mysql_password do + required_param 'String', :password + return_type 'String' + end + + def mysql_password(password) + call_function('deprecation', 'mysql_password', "This method has been deprecated, please use the namespaced version 'mysql::password' instead.") + call_function('mysql::password', password) + end +end diff --git a/lib/puppet/parser/functions/mysql_password.rb b/lib/puppet/parser/functions/mysql_password.rb deleted file mode 100644 index 53ba580b2..000000000 --- a/lib/puppet/parser/functions/mysql_password.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'digest/sha1' -module Puppet::Parser::Functions - newfunction(:mysql_password, type: :rvalue, doc: <<-EOS - @summary - Hash a string as mysql's "PASSWORD()" function would do it - - @param [String] password Plain text password. - - @return [String] the mysql password hash from the clear text password. - EOS - ) do |args| - - if args.size != 1 - raise Puppet::ParseError, _('mysql_password(): Wrong number of arguments given (%{args_length} for 1)') % { args_length: args.length } - end - - return '' if args[0].empty? - return args[0] if args[0] =~ %r{\*[A-F0-9]{40}$} - '*' + Digest::SHA1.hexdigest(Digest::SHA1.digest(args[0])).upcase - end -end diff --git a/lib/puppet/provider/mysql_login_path/inifile.rb b/lib/puppet/provider/mysql_login_path/inifile.rb new file mode 100644 index 000000000..d15632ddf --- /dev/null +++ b/lib/puppet/provider/mysql_login_path/inifile.rb @@ -0,0 +1,632 @@ +# encoding: UTF-8 + +# See: https://github.com/puppetlabs/puppet/blob/master/lib/puppet/util/inifile.rb +# This class represents the INI file and can be used to parse, modify, +# and write INI files. +class Puppet::Provider::MysqlLoginPath::IniFile < Puppet::Provider + include Enumerable + + class Error < StandardError; end + # VERSION = '3.0.0' + + # Public: Open an INI file and load the contents. + # + # filename - The name of the file as a String + # opts - The Hash of options (default: {}) + # :comment - String containing the comment character(s) + # :parameter - String used to separate parameter and value + # :encoding - Encoding String for reading / writing + # :default - The String name of the default global section + # + # Examples + # + # IniFile.load('file.ini') + # #=> IniFile instance + # + # IniFile.load('does/not/exist.ini') + # #=> nil + # + # Returns an IniFile instance or nil if the file could not be opened. + def self.load(filename, opts = {}) + return unless File.file? filename + new(opts.merge(filename: filename)) + end + + # Get and set the filename + attr_accessor :filename + + # Get and set the encoding + attr_accessor :encoding + + # Public: Create a new INI file from the given set of options. If :content + # is provided then it will be used to populate the INI file. If a :filename + # is provided then the contents of the file will be parsed and stored in the + # INI file. If neither the :content or :filename is provided then an empty + # INI file is created. + # + # opts - The Hash of options (default: {}) + # :content - The String/Hash containing the INI contents + # :comment - String containing the comment character(s) + # :parameter - String used to separate parameter and value + # :encoding - Encoding String for reading / writing + # :default - The String name of the default global section + # :filename - The filename as a String + # + # Examples + # + # IniFile.new + # #=> an empty IniFile instance + # + # IniFile.new( :content => "[global]\nfoo=bar" ) + # #=> an IniFile instance + # + # IniFile.new( :filename => 'file.ini', :encoding => 'UTF-8' ) + # #=> an IniFile instance + # + # IniFile.new( :content => "[global]\nfoo=bar", :comment => '#' ) + # #=> an IniFile instance + # + def initialize(opts = {}) + @comment = opts.fetch(:comment, ';#') + @param = opts.fetch(:parameter, '=') + @encoding = opts.fetch(:encoding, nil) + @default = opts.fetch(:default, 'global') + @filename = opts.fetch(:filename, nil) + content = opts.fetch(:content, nil) + + @ini = Hash.new { |h, k| h[k] = {} } + + if content.is_a?(Hash) then merge!(content) + elsif content then parse(content) + elsif @filename then read + end + end + + # Public: Write the contents of this IniFile to the file system. If left + # unspecified, the currently configured filename and encoding will be used. + # Otherwise the filename and encoding can be specified in the options hash. + # + # opts - The default options Hash + # :filename - The filename as a String + # :encoding - The encoding as a String + # + # Returns this IniFile instance. + def write(opts = {}) + filename = opts.fetch(:filename, @filename) + encoding = opts.fetch(:encoding, @encoding) + mode = encoding ? "w:#{encoding}" : 'w' + + File.open(filename, mode) do |f| + @ini.each do |section, hash| + f.puts "[#{section}]" + hash.each { |param, val| f.puts "#{param} #{@param} #{escape_value val}" } + f.puts + end + end + + self + end + alias save write + + # Public: Read the contents of the INI file from the file system and replace + # and set the state of this IniFile instance. If left unspecified the + # currently configured filename and encoding will be used when reading from + # the file system. Otherwise the filename and encoding can be specified in + # the options hash. + # + # opts - The default options Hash + # :filename - The filename as a String + # :encoding - The encoding as a String + # + # Returns this IniFile instance if the read was successful; nil is returned + # if the file could not be read. + def read(opts = {}) + filename = opts.fetch(:filename, @filename) + encoding = opts.fetch(:encoding, @encoding) + return unless File.file? filename + + mode = encoding ? "r:#{encoding}" : 'r' + File.open(filename, mode) { |fd| parse fd } + self + end + alias restore read + + # Returns this IniFile converted to a String. + def to_s + s = [] + @ini.each do |section, hash| + s << "[#{section}]" + hash.each { |param, val| s << "#{param} #{@param} #{escape_value val}" } + s << '' + end + s.join("\n") + end + + # Returns this IniFile converted to a Hash. + def to_h + @ini.dup + end + + # Public: Creates a copy of this inifile with the entries from the + # other_inifile merged into the copy. + # + # other - The other IniFile. + # + # Returns a new IniFile. + def merge(other) + dup.merge!(other) + end + + # Public: Merges other_inifile into this inifile, overwriting existing + # entries. Useful for having a system inifile with user overridable settings + # elsewhere. + # + # other - The other IniFile. + # + # Returns this IniFile. + def merge!(other) + return self if other.nil? + + my_keys = @ini.keys + other_keys = case other + when IniFile + other.instance_variable_get(:@ini).keys + when Hash + other.keys + else + raise Error, "cannot merge contents from '#{other.class.name}'" + end + + (my_keys & other_keys).each do |key| + case other[key] + when Hash + @ini[key].merge!(other[key]) + when nil + nil + else + raise Error, "cannot merge section #{key.inspect} - unsupported type: #{other[key].class.name}" + end + end + + (other_keys - my_keys).each do |key| + @ini[key] = case other[key] + when Hash + other[key].dup + when nil + {} + else + raise Error, "cannot merge section #{key.inspect} - unsupported type: #{other[key].class.name}" + end + end + + self + end + + # Public: Yield each INI file section, parameter, and value in turn to the + # given block. + # + # block - The block that will be iterated by the each method. The block will + # be passed the current section and the parameter/value pair. + # + # Examples + # + # inifile.each do |section, parameter, value| + # puts "#{parameter} = #{value} [in section - #{section}]" + # end + # + # Returns this IniFile. + def each + return unless block_given? + @ini.each do |section, hash| + hash.each do |param, val| + yield section, param, val + end + end + self + end + + # Public: Yield each section in turn to the given block. + # + # block - The block that will be iterated by the each method. The block will + # be passed the current section as a Hash. + # + # Examples + # + # inifile.each_section do |section| + # puts section.inspect + # end + # + # Returns this IniFile. + def each_section + return unless block_given? + @ini.each_key { |section| yield section } + self + end + + # Public: Remove a section identified by name from the IniFile. + # + # section - The section name as a String. + # + # Returns the deleted section Hash. + def delete_section(section) + @ini.delete section.to_s + end + + # Public: Get the section Hash by name. If the section does not exist, then + # it will be created. + # + # section - The section name as a String. + # + # Examples + # + # inifile['global'] + # #=> global section Hash + # + # Returns the Hash of parameter/value pairs for this section. + def [](section) + return nil if section.nil? + @ini[section.to_s] + end + + # Public: Set the section to a hash of parameter/value pairs. + # + # section - The section name as a String. + # value - The Hash of parameter/value pairs. + # + # Examples + # + # inifile['tenderloin'] = { 'gritty' => 'yes' } + # #=> { 'gritty' => 'yes' } + # + # Returns the value Hash. + def []=(section, value) + @ini[section.to_s] = value + end + + # Public: Create a Hash containing only those INI file sections whose names + # match the given regular expression. + # + # regex - The Regexp used to match section names. + # + # Examples + # + # inifile.match(/^tree_/) + # #=> Hash of matching sections + # + # Return a Hash containing only those sections that match the given regular + # expression. + def match(regex) + @ini.dup.delete_if { |section, _| section !~ regex } + end + + # Public: Check to see if the IniFile contains the section. + # + # section - The section name as a String. + # + # Returns true if the section exists in the IniFile. + def section?(section) + @ini.key? section.to_s + end + + # Returns an Array of section names contained in this IniFile. + def sections + @ini.keys + end + + # Public: Freeze the state of this IniFile object. Any attempts to change + # the object will raise an error. + # + # Returns this IniFile. + def freeze + super + @ini.each_value { |h| h.freeze } + @ini.freeze + self + end + + # Public: Mark this IniFile as tainted -- this will traverse each section + # marking each as tainted. + # + # Returns this IniFile. + def taint + super + @ini.each_value { |h| h.taint } + @ini.taint + self + end + + # Public: Produces a duplicate of this IniFile. The duplicate is independent + # of the original -- i.e. the duplicate can be modified without changing the + # original. The tainted state of the original is copied to the duplicate. + # + # Returns a new IniFile. + def dup + other = super + other.instance_variable_set(:@ini, Hash.new { |h, k| h[k] = {} }) + @ini.each_pair { |s, h| other[s].merge! h } + other.taint if tainted? + other + end + + # Public: Produces a duplicate of this IniFile. The duplicate is independent + # of the original -- i.e. the duplicate can be modified without changing the + # original. The tainted state and the frozen state of the original is copied + # to the duplicate. + # + # Returns a new IniFile. + def clone + other = dup + other.freeze if frozen? + other + end + + # Public: Compare this IniFile to some other IniFile. For two INI files to + # be equivalent, they must have the same sections with the same parameter / + # value pairs in each section. + # + # other - The other IniFile. + # + # Returns true if the INI files are equivalent and false if they differ. + def eql?(other) + return true if equal? other + return false unless other.instance_of? self.class + @ini == other.instance_variable_get(:@ini) + end + alias == eql? + + # Escape special characters. + # + # value - The String value to escape. + # + # Returns the escaped value. + def escape_value(value) + value = value.to_s.dup + value.gsub!(%r{\\([0nrt])}, '\\\\\1') + value.gsub!(%r{\n}, '\n') + value.gsub!(%r{\r}, '\r') + value.gsub!(%r{\t}, '\t') + value.gsub!(%r{\0}, '\0') + value + end + + # Parse the given content and store the information in this IniFile + # instance. All data will be cleared out and replaced with the information + # read from the content. + # + # content - A String or a file descriptor (must respond to `each_line`) + # + # Returns this IniFile. + def parse(content) + parser = Parser.new(@ini, @param, @comment, @default) + parser.parse(content) + self + end + + # The IniFile::Parser has the responsibility of reading the contents of an + # .ini file and storing that information into a ruby Hash. The object being + # parsed must respond to `each_line` - this includes Strings and any IO + # object. + class Parser + attr_writer :section + attr_accessor :property + attr_accessor :value + + # Create a new IniFile::Parser that can be used to parse the contents of + # an .ini file. + # + # hash - The Hash where parsed information will be stored + # param - String used to separate parameter and value + # comment - String containing the comment character(s) + # default - The String name of the default global section + # + def initialize(hash, param, comment, default) + @hash = hash + @default = default + + comment = comment.to_s.empty? ? '\\z' : "\\s*(?:[#{comment}].*)?\\z" + + @section_regexp = %r{\A\s*\[([^\]]+)\]#{comment}} + @ignore_regexp = %r{\A#{comment}} + @property_regexp = %r{\A(.*?)(? true + # "false" --> false + # "" --> nil + # "42" --> 42 + # "3.14" --> 3.14 + # "foo" --> "foo" + # + # Returns the typecast value. + def typecast(value) + case value + when %r{\Atrue\z}i then true + when %r{\Afalse\z}i then false + when %r{\A\s*\z}i then nil + else + begin + begin + Integer(value) + rescue + Float(value) + end + rescue + unescape_value(value) + end + end + end + + # Unescape special characters found in the value string. This will convert + # escaped null, tab, carriage return, newline, and backslash into their + # literal equivalents. + # + # value - The String value to unescape. + # + # Returns the unescaped value. + def unescape_value(value) + value = value.to_s + value.gsub!(%r{\\[0nrt\\]}) do |char| + case char + when '\0' then "\0" + when '\n' then "\n" + when '\r' then "\r" + when '\t' then "\t" + when '\\\\' then '\\' + end + end + value + end + end +end # IniFile diff --git a/lib/puppet/provider/mysql_login_path/mysql_login_path.rb b/lib/puppet/provider/mysql_login_path/mysql_login_path.rb new file mode 100644 index 000000000..de71bebce --- /dev/null +++ b/lib/puppet/provider/mysql_login_path/mysql_login_path.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require File.expand_path(File.join(File.dirname(__FILE__), 'inifile')) +require File.expand_path(File.join(File.dirname(__FILE__), 'sensitive')) +require 'puppet/resource_api/simple_provider' +require 'puppet/util/execution' +require 'puppet/util/suidmanager' +require 'open3' + +# Implementation for the mysql_login_path type using the Resource API. +class Puppet::Provider::MysqlLoginPath::MysqlLoginPath < Puppet::ResourceApi::SimpleProvider + def get_homedir(_context, uid) + result = Puppet::Util::Execution.execute(['/usr/bin/getent', 'passwd', uid], failonfail: true) + result.split(':')[5] + end + + def mysql_config_editor_set_cmd(context, uid, password = nil, *args) + args.unshift('/usr/bin/mysql_config_editor') + homedir = get_homedir(context, uid) + + if args.is_a?(Array) + command = args.flatten.map(&:to_s) + command_str = command.join(' ') + elsif args.is_a?(String) + command_str = command + end + + Puppet::Util::SUIDManager.asuser(uid) do + @exit_status = Open3.popen3({ 'HOME' => homedir }, command_str) do |stdin, stdout, stderr, wait_thr| + if password + stdin.puts(password + "\r\n") + stdin.close + end + @captured_stdout = stdout.read + @captured_stderr = stderr.read + wait_thr.value + end + end + + if @exit_status.success? == false + raise Puppet::ExecutionFailure, _( + "Execution of '%{str}' returned %{exit_status}: %{output}", + ) % { + str: command_str, + exit_status: @exit_status, + output: @captured_stderr.strip, + } + end + @captured_stdout + end + + def mysql_config_editor_cmd(context, uid, *args) + args.unshift('/usr/bin/mysql_config_editor') + homedir = get_homedir(context, uid) + Puppet::Util::Execution.execute( + args, + failonfail: true, + uid: uid, + custom_environment: { 'HOME' => homedir }, + ) + end + + def my_print_defaults_cmd(context, uid, *args) + args.unshift('/usr/bin/my_print_defaults') + homedir = get_homedir(context, uid) + Puppet::Util::Execution.execute( + args, + failonfail: true, + uid: uid, + custom_environment: { 'HOME' => homedir }, + ) + end + + def get_password(context, uid, name) + result = '' + output = my_print_defaults_cmd(context, uid, '-s', name) + output.split("\n").each do |line| + if line =~ %r{\-\-password} + result = line.sub(%r{\-\-password=}, '') + end + end + result + end + + def save_login_path(context, name, should) + uid = name.fetch(:owner) + + args = ['set', '--skip-warn'] + args.push('-G', should[:name].to_s) if should[:name] + args.push('-h', should[:host].to_s) if should[:host] + args.push('-u', should[:user].to_s) if should[:user] + args.push('-S', should[:socket].to_s) if should[:socket] + args.push('-P', should[:port].to_s) if should[:port] + args.push('-p') if should[:password] && extract_pw(should[:password]) + password = (should[:password] && extract_pw(should[:password])) ? extract_pw(should[:password]) : nil + + mysql_config_editor_set_cmd(context, uid, password, args) + end + + def delete_login_path(context, name) + login_path = name.fetch(:name) + uid = name.fetch(:owner) + mysql_config_editor_cmd(context, uid, 'remove', '-G', login_path) + end + + def gen_pw(pw) + Puppet::Provider::MysqlLoginPath::Sensitive.new(pw) + end + + def extract_pw(sensitive) + sensitive.unwrap + end + + def list_login_paths(context, uid) + result = [] + output = mysql_config_editor_cmd(context, uid, 'print', '--all') + ini = Puppet::Provider::MysqlLoginPath::IniFile.new(content: output) + ini.each_section do |section| + result.push(ensure: 'present', + name: section, + owner: uid.to_s, + title: section + '-' + uid.to_s, + host: ini[section]['host'].nil? ? nil : ini[section]['host'], + user: ini[section]['user'].nil? ? nil : ini[section]['user'], + password: ini[section]['password'].nil? ? nil : gen_pw(get_password(context, uid, section)), + socket: ini[section]['socket'].nil? ? nil : ini[section]['socket'], + port: ini[section]['port'].nil? ? nil : ini[section]['port']) + end + result + end + + def get(context, name) + result = [] + owner = name.empty? ? ['root'] : name.map { |item| item[:owner] }.compact.uniq + owner.each do |uid| + login_paths = list_login_paths(context, uid) + result += login_paths + end + result + end + + def create(context, name, should) + save_login_path(context, name, should) + end + + def update(context, name, should) + delete_login_path(context, name) + save_login_path(context, name, should) + end + + def delete(context, name) + delete_login_path(context, name) + end + + def canonicalize(_context, resources) + resources.each do |r| + if r.key?(:password) && r[:password].is_a?(Puppet::Pops::Types::PSensitiveType::Sensitive) + r[:password] = gen_pw(extract_pw(r[:password])) + end + end + end +end diff --git a/lib/puppet/provider/mysql_login_path/sensitive.rb b/lib/puppet/provider/mysql_login_path/sensitive.rb new file mode 100644 index 000000000..4876504aa --- /dev/null +++ b/lib/puppet/provider/mysql_login_path/sensitive.rb @@ -0,0 +1,7 @@ +# A Puppet Language type that makes the Sensitive Type comparable +# +class Puppet::Provider::MysqlLoginPath::Sensitive < Puppet::Pops::Types::PSensitiveType::Sensitive + def ==(other) + return true if other.is_a?(Puppet::Pops::Types::PSensitiveType::Sensitive) && unwrap == other.unwrap + end +end diff --git a/lib/puppet/type/mysql_login_path.rb b/lib/puppet/type/mysql_login_path.rb new file mode 100644 index 000000000..c7dd24b8a --- /dev/null +++ b/lib/puppet/type/mysql_login_path.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'puppet/resource_api' + +Puppet::ResourceApi.register_type( + name: 'mysql_login_path', + + docs: <<-EOS, + @summary + Manage a MySQL login path. + @see + https://dev.mysql.com/doc/refman/8.0/en/mysql-config-editor.html + @example + mysql_login_path { 'local_socket': + owner => 'root', + host => 'localhost', + user => 'root', + password => Sensitive('secure'), + socket => '/var/run/mysql/mysql.sock', + ensure => present, + } + + mysql_login_path { 'local_tcp': + owner => 'root', + host => '127.0.0.1', + user => 'root', + password => Sensitive('more_secure'), + port => 3306, + ensure => present, + } + + This type provides Puppet with the capabilities to store authentication credentials in an obfuscated login path file + named .mylogin.cnf created with the mysql_config_editor utility. Supports only MySQL Community Edition > v5.6.6. +EOS + features: ['simple_get_filter', 'canonicalize'], + title_patterns: [ + { + pattern: %r{^(?.*[^-])-(?.*)$}, + desc: 'Where the name of the and the owner are provided with a hyphen seperator', + }, + { + pattern: %r{^(?.*)$}, + desc: 'Where only the name is provided', + }, + ], + attributes: { + ensure: { + type: 'Enum[present, absent]', + desc: 'Whether this resource should be present or absent on the target system.', + }, + name: { + type: 'String', + desc: 'Name of the login path you want to manage.', + behaviour: :namevar, + }, + owner: { + type: 'String', + desc: 'The user to whom the logon path should belong.', + behaviour: :namevar, + default: 'root', + }, + host: { + type: 'Optional[String]', + desc: 'Host name to be entered into the login path.', + }, + user: { + type: 'Optional[String]', + desc: 'Username to be entered into the login path.', + }, + password: { + type: 'Optional[Sensitive[String[1]]]', + desc: 'Password to be entered into login path', + }, + socket: { + type: 'Optional[String]', + desc: 'Socket path to be entered into login path', + }, + port: { + type: 'Optional[Integer[0,65535]]', + desc: 'Port number to be entered into login path.', + }, + }, +) diff --git a/lib/puppet/type/mysql_user.rb b/lib/puppet/type/mysql_user.rb index a2d3c7856..e008375a4 100644 --- a/lib/puppet/type/mysql_user.rb +++ b/lib/puppet/type/mysql_user.rb @@ -47,7 +47,7 @@ end newproperty(:password_hash) do - desc 'The password hash of the user. Use mysql_password() for creating such a hash.' + desc 'The password hash of the user. Use mysql::password() for creating such a hash.' newvalue(%r{\w*}) def change_to_s(currentvalue, _newvalue) diff --git a/manifests/backup/mysqlbackup.pp b/manifests/backup/mysqlbackup.pp index ab0bd17a5..730264952 100644 --- a/manifests/backup/mysqlbackup.pp +++ b/manifests/backup/mysqlbackup.pp @@ -27,6 +27,7 @@ $postscript = false, $execpath = '/usr/bin:/usr/sbin:/bin:/sbin', $optional_args = [], + $incremental_backups = false, ) inherits mysql::params { mysql_user { "${backupuser}@localhost": @@ -65,17 +66,11 @@ } if $::osfamily == 'RedHat' and $::operatingsystemmajrelease == '5' { - package {'crontabs': - ensure => present, - } + ensure_packages('crontabs') } elsif $::osfamily == 'RedHat' { - package {'cronie': - ensure => present, - } + ensure_packages('cronie') } elsif $::osfamily != 'FreeBSD' { - package {'cron': - ensure => present, - } + ensure_packages('cron') } cron { 'mysqlbackup-weekly': diff --git a/manifests/backup/mysqldump.pp b/manifests/backup/mysqldump.pp index 51155081a..0fb07b298 100644 --- a/manifests/backup/mysqldump.pp +++ b/manifests/backup/mysqldump.pp @@ -28,6 +28,7 @@ $optional_args = [], $mysqlbackupdir_ensure = 'directory', $mysqlbackupdir_target = undef, + $incremental_backups = false, ) inherits mysql::params { unless $::osfamily == 'FreeBSD' { diff --git a/manifests/backup/xtrabackup.pp b/manifests/backup/xtrabackup.pp index 1f8234ec8..305e891d2 100644 --- a/manifests/backup/xtrabackup.pp +++ b/manifests/backup/xtrabackup.pp @@ -51,9 +51,18 @@ } if $incremental_backups { + # Warn if old backups are removed too soon. Incremental backups will fail + # if the full backup is no longer available. + if ($backuprotate.convert_to(Integer) < 7) { + warning(translate('The value for `backuprotate` is too low, it must be set to at least 7 days when using incremental backups.')) + } + + # The --target-dir uses a more predictable value for the full backup so + # that it can easily be calculated and used in incremental backup jobs. + # Besides that it allows to have multiple full backups. cron { 'xtrabackup-weekly': ensure => $ensure, - command => "/usr/local/sbin/xtrabackup.sh --target-dir=${backupdir} ${additional_cron_args}", + command => "/usr/local/sbin/xtrabackup.sh --target-dir=${backupdir}/$(date +\\%F)_full ${additional_cron_args}", user => 'root', hour => $time[0], minute => $time[1], @@ -62,13 +71,23 @@ } } + # Wether to use GNU or BSD date format. + case $::osfamily { + 'FreeBSD','OpenBSD': { + $dateformat = '$(date -v-sun +\\%F)_full' + } + default: { + $dateformat = '$(date -d "last sunday" +\\%F)_full' + } + } + $daily_cron_data = ($incremental_backups) ? { true => { - 'directories' => "--incremental-basedir=${backupdir} --target-dir=${backupdir}/$(date +\\%F_\\%H-\\%M-\\%S)", + 'directories' => "--incremental-basedir=${backupdir}/${dateformat} --target-dir=${backupdir}/$(date +\\%F_\\%H-\\%M-\\%S)", 'weekday' => '1-6', }, false => { - 'directories' => "--target-dir=${backupdir}", + 'directories' => "--target-dir=${backupdir}/$(date +\\%F_\\%H-\\%M-\\%S)", 'weekday' => '*', }, } diff --git a/manifests/params.pp b/manifests/params.pp index 4092882ee..eb475214f 100644 --- a/manifests/params.pp +++ b/manifests/params.pp @@ -38,7 +38,7 @@ $client_dev_package_provider = undef $daemon_dev_package_ensure = 'present' $daemon_dev_package_provider = undef - $xtrabackup_package_name = 'percona-xtrabackup' + $xtrabackup_package_name_default = 'percona-xtrabackup' case $::osfamily { @@ -55,8 +55,12 @@ /^(RedHat|CentOS|Scientific|OracleLinux)$/: { if versioncmp($::operatingsystemmajrelease, '7') >= 0 { $provider = 'mariadb' + if versioncmp($::operatingsystemmajrelease, '8') >= 0 { + $xtrabackup_package_name_override = 'percona-xtrabackup-24' + } } else { $provider = 'mysql' + $xtrabackup_package_name_override = 'percona-xtrabackup-20' } if versioncmp($::operatingsystemmajrelease, '8') >= 0 { $java_package_name = 'mariadb-java-client' @@ -158,6 +162,7 @@ $mycnf_owner = undef $mycnf_group = undef $server_service_name = 'mysql' + $xtrabackup_package_name_override = 'xtrabackup' if $::operatingsystem =~ /(SLES|SLED)/ { if versioncmp( $::operatingsystemmajrelease, '12' ) >= 0 { @@ -237,6 +242,10 @@ } else { $php_package_name = 'php5-mysql' } + if ($::operatingsystem == 'Ubuntu' and versioncmp($::operatingsystemrelease, '16.04') < 0) or + ($::operatingsystem == 'Debian') { + $xtrabackup_package_name_override = 'percona-xtrabackup-24' + } $python_package_name = 'python-mysqldb' $ruby_package_name = $::lsbdistcodename ? { @@ -548,6 +557,12 @@ }, } + if defined('$xtrabackup_package_name_override') { + $xtrabackup_package_name = pick($xtrabackup_package_name_override, $xtrabackup_package_name_default) + } else { + $xtrabackup_package_name = $xtrabackup_package_name_default + } + ## Additional graceful failures if $::osfamily == 'RedHat' and $::operatingsystemmajrelease == '4' and $::operatingsystem != 'Amazon' { fail(translate('Unsupported platform: puppetlabs-%{module_name} only supports RedHat 5.0 and beyond.', {'module_name' => $module_name})) diff --git a/manifests/server/backup.pp b/manifests/server/backup.pp index 07345f00e..5cc780fe9 100644 --- a/manifests/server/backup.pp +++ b/manifests/server/backup.pp @@ -48,6 +48,8 @@ # Dump stored routines (procedures and functions) from dumped databases when doing a `file_per_database` backup. # @param include_triggers # Dump triggers for each dumped table when doing a `file_per_database` backup. +# @param incremental_backups +# A flag to activate/deactivate incremental backups. Currently only supported by the xtrabackup provider. # @param ensure # @param time # An array of two elements to set the backup time. Allows ['23', '5'] (i.e., 23:05) or ['3', '45'] (i.e., 03:45) for HH:MM times. @@ -88,6 +90,7 @@ $provider = 'mysqldump', $maxallowedpacket = '1M', $optional_args = [], + $incremental_backups = true, ) inherits mysql::params { if $prescript and $provider =~ /(mysqldump|mysqlbackup)/ { @@ -120,6 +123,7 @@ 'execpath' => $execpath, 'maxallowedpacket' => $maxallowedpacket, 'optional_args' => $optional_args, + 'incremental_backups' => $incremental_backups, } }) } diff --git a/manifests/server/managed_dirs.pp b/manifests/server/managed_dirs.pp index c82a59220..fdb10b75f 100644 --- a/manifests/server/managed_dirs.pp +++ b/manifests/server/managed_dirs.pp @@ -1,5 +1,5 @@ # @summary -# Binary log configuration requires the mysql user to be present. This must be done after package install +# Binary log configuration requires the mysql user to be present. This must be done after package install. # # @api private # diff --git a/metadata.json b/metadata.json index 57a64df70..cde5ee633 100644 --- a/metadata.json +++ b/metadata.json @@ -1,6 +1,6 @@ { "name": "puppetlabs-mysql", - "version": "10.4.0", + "version": "10.5.0", "author": "puppetlabs", "summary": "Installs, configures, and manages the MySQL service.", "license": "Apache-2.0", @@ -15,6 +15,10 @@ { "name": "puppetlabs/translate", "version_requirement": ">= 1.0.0 < 3.0.0" + }, + { + "name": "puppetlabs/resource_api", + "version_requirement": ">= 1.0.0 < 2.0.0" } ], "operatingsystem_support": [ @@ -84,6 +88,6 @@ ], "description": "MySQL module", "template-url": "https://github.com/puppetlabs/pdk-templates#master", - "template-ref": "1.17.0-0-gd3a4319", + "template-ref": "heads/master-0-g095317c", "pdk-version": "1.17.0" } diff --git a/provision.yaml b/provision.yaml index a8225a8b3..a276e94a0 100644 --- a/provision.yaml +++ b/provision.yaml @@ -18,5 +18,5 @@ travis_el7: provisioner: docker_exp images: ['litmusimage/centos:7', 'litmusimage/oraclelinux:7', 'litmusimage/scientificlinux:7'] release_checks: - provisioner: vmpooler + provisioner: abs images: ['redhat-5-x86_64', 'redhat-6-x86_64', 'redhat-7-x86_64', 'redhat-8-x86_64', 'centos-5-x86_64', 'centos-6-x86_64', 'centos-7-x86_64', 'centos-8-x86_64', 'oracle-5-x86_64', 'oracle-6-x86_64', 'oracle-7-x86_64', 'scientific-6-x86_64', 'scientific-7-x86_64', 'debian-8-x86_64', 'debian-9-x86_64', 'debian-10-x86_64', 'sles-11-x86_64', 'ubuntu-1404-x86_64', 'ubuntu-1604-x86_64', 'ubuntu-1804-x86_64'] diff --git a/spec/acceptance/mysql_backup_spec.rb b/spec/acceptance/mysql_backup_spec.rb index 057a34072..34080bdb2 100644 --- a/spec/acceptance/mysql_backup_spec.rb +++ b/spec/acceptance/mysql_backup_spec.rb @@ -134,3 +134,220 @@ class { 'mysql::server::backup': # rubocop:enable RSpec/MultipleExpectations, RSpec/ExampleLength end end + +context 'with xtrabackup enabled' do + context 'should work with no errors', if: ((os[:family] == 'debian' && os[:release].to_i >= 8) || (os[:family] == 'ubuntu' && os[:release] =~ %r{^16\.04|^18\.04}) || (os[:family] == 'redhat' && os[:release].to_i > 6)) do # rubocop:disable Metrics/LineLength + pp = <<-MANIFEST + class { 'mysql::server': root_password => 'password' } + mysql::db { [ + 'backup1', + 'backup2' + ]: + user => 'backup', + password => 'secret', + } + case $facts['os']['family'] { + /Debian/: { + file { '/tmp/percona-release_latest.deb': + ensure => present, + source => "http://repo.percona.com/apt/percona-release_latest.${facts['os']['distro']['codename']}_all.deb", + } + ensure_packages('gnupg') + ensure_packages('gnupg2') + ensure_packages('percona-release',{ + ensure => present, + provider => 'dpkg', + source => '/tmp/percona-release_latest.deb', + notify => Exec['apt-get update'], + }) + exec { 'apt-get update': + path => '/usr/bin:/usr/sbin:/bin:/sbin', + refreshonly => true, + } + } + /RedHat/: { + # RHEL/CentOS 5 is no longer supported by Percona, but older versions + # of the repository are still available. + if versioncmp($::operatingsystemmajrelease, '6') >= 0 { + $percona_url = 'http://repo.percona.com/yum/percona-release-latest.noarch.rpm' + $epel_url = "https://download.fedoraproject.org/pub/epel/epel-release-latest-${facts['os']['release']['major']}.noarch.rpm" + } else { + $percona_url = 'http://repo.percona.com/yum/release/5/os/noarch/percona-release-0.1-3.noarch.rpm' + $epel_url = 'https://archives.fedoraproject.org/pub/archive/epel/epel-release-latest-5.noarch.rpm' + } + ensure_packages('percona-release',{ + ensure => present, + provider => 'rpm', + source => $percona_url, + }) + ensure_packages('epel-release',{ + ensure => present, + provider => 'rpm', + source => $epel_url, + }) + if ($facts['os']['name'] == 'Scientific') { + # $releasever resolves to '6.10' instead of '6' which breaks Percona repos + file { '/etc/yum/vars/releasever': + ensure => present, + content => '6', + } + } + } + default: { } + } + class { 'mysql::server::backup': + backupuser => 'myuser', + backuppassword => 'mypassword', + backupdir => '/tmp/xtrabackups', + provider => 'xtrabackup', + execpath => '/usr/bin:/usr/sbin:/bin:/sbin:/opt/zimbra/bin', + } + MANIFEST + it 'when configuring mysql backup' do + idempotent_apply(pp) + end + end + + describe 'xtrabackup.sh', if: Gem::Version.new(mysql_version) < Gem::Version.new('5.7.0') && ((os[:family] == 'debian' && os[:release].to_i >= 8) || (os[:family] == 'ubuntu' && os[:release] =~ %r{^16\.04|^18\.04}) || (os[:family] == 'redhat' && os[:release].to_i > 6)) do # rubocop:disable Metrics/LineLength + before(:all) do + pre_run + end + + it 'runs xtrabackup.sh full backup with no errors' do + run_shell('/usr/local/sbin/xtrabackup.sh --target-dir=/tmp/xtrabackups/$(date +%F)_full --backup 2>&1 | tee /tmp/xtrabackup_full.log') do |r| + expect(r.exit_code).to be_zero + end + end + + it 'xtrabackup reports success for the full backup' do + # NOTE: Once support for CentOS 6 is dropped, we should check for "completed OK" instead. + run_shell('grep "xtrabackup: Transaction log of lsn" /tmp/xtrabackup_full.log') do |r| + expect(r.exit_code).to be_zero + end + end + + it 'creates a subdirectory for the full backup' do + run_shell('find /tmp/xtrabackups -mindepth 1 -maxdepth 1 -type d -name $(date +%Y)\*full | wc -l') do |r| + expect(r.stdout).to match(%r{1}) + expect(r.exit_code).to be_zero + end + end + + it 'runs xtrabackup.sh incremental backup with no errors' do + run_shell('sleep 1') + run_shell('/usr/local/sbin/xtrabackup.sh --incremental-basedir=/tmp/xtrabackups/$(date +%F)_full --target-dir=/tmp/xtrabackups/$(date +%F_%H-%M-%S) --backup 2>&1 | tee /tmp/xtrabackup_inc.log') do |r| # rubocop:disable Metrics/LineLength + expect(r.exit_code).to be_zero + end + end + + it 'xtrabackup reports success for the incremental backup' do + # NOTE: Once support for CentOS 6 is dropped, we should check for "completed OK" instead. + run_shell('grep "xtrabackup: Transaction log of lsn" /tmp/xtrabackup_inc.log') do |r| + expect(r.exit_code).to be_zero + end + end + + it 'creates a new subdirectory for each backup' do + run_shell('find /tmp/xtrabackups -mindepth 1 -maxdepth 1 -type d -name $(date +%Y)\* | wc -l') do |r| + expect(r.stdout).to match(%r{2}) + expect(r.exit_code).to be_zero + end + end + end + # rubocop:enable RSpec/MultipleExpectations, RSpec/ExampleLength +end + +context 'with xtrabackup enabled and incremental backups disabled' do + context 'should work with no errors', if: ((os[:family] == 'debian' && os[:release].to_i >= 8) || (os[:family] == 'ubuntu' && os[:release] =~ %r{^16\.04|^18\.04}) || (os[:family] == 'redhat' && os[:release].to_i > 6)) do # rubocop:disable Metrics/LineLength + pp = <<-MANIFEST + class { 'mysql::server': root_password => 'password' } + mysql::db { [ + 'backup1', + 'backup2' + ]: + user => 'backup', + password => 'secret', + } + case $facts['os']['family'] { + /Debian/: { + file { '/tmp/percona-release_latest.deb': + ensure => present, + source => "http://repo.percona.com/apt/percona-release_latest.${facts['os']['distro']['codename']}_all.deb", + } + ensure_packages('gnupg') + ensure_packages('gnupg2') + ensure_packages('percona-release',{ + ensure => present, + provider => 'dpkg', + source => '/tmp/percona-release_latest.deb', + notify => Exec['apt-get update'], + }) + exec { 'apt-get update': + path => '/usr/bin:/usr/sbin:/bin:/sbin', + refreshonly => true, + } + } + /RedHat/: { + # RHEL/CentOS 5 is no longer supported by Percona, but older versions + # of the repository are still available. + if versioncmp($::operatingsystemmajrelease, '6') >= 0 { + $percona_url = 'http://repo.percona.com/yum/percona-release-latest.noarch.rpm' + $epel_url = "https://download.fedoraproject.org/pub/epel/epel-release-latest-${facts['os']['release']['major']}.noarch.rpm" + } else { + $percona_url = 'http://repo.percona.com/yum/release/5/os/noarch/percona-release-0.1-3.noarch.rpm' + $epel_url = 'https://archives.fedoraproject.org/pub/archive/epel/epel-release-latest-5.noarch.rpm' + } + ensure_packages('percona-release',{ + ensure => present, + provider => 'rpm', + source => $percona_url, + }) + ensure_packages('epel-release',{ + ensure => present, + provider => 'rpm', + source => $epel_url, + }) + if ($facts['os']['name'] == 'Scientific') { + # $releasever resolves to '6.10' instead of '6' which breaks Percona repos + file { '/etc/yum/vars/releasever': + ensure => present, + content => '6', + } + } + } + default: { } + } + class { 'mysql::server::backup': + backupuser => 'myuser', + backuppassword => 'mypassword', + backupdir => '/tmp/xtrabackups', + provider => 'xtrabackup', + incremental_backups => false, + execpath => '/usr/bin:/usr/sbin:/bin:/sbin:/opt/zimbra/bin', + } + MANIFEST + it 'when configuring mysql backup' do + idempotent_apply(pp) + end + end + + describe 'xtrabackup.sh', if: Gem::Version.new(mysql_version) < Gem::Version.new('5.7.0') && ((os[:family] == 'debian' && os[:release].to_i >= 8) || (os[:family] == 'ubuntu' && os[:release] =~ %r{^16\.04|^18\.04}) || (os[:family] == 'redhat' && os[:release].to_i > 6)) do # rubocop:disable Metrics/LineLength + before(:all) do + pre_run + end + + it 'runs xtrabackup.sh with no errors' do + run_shell('/usr/local/sbin/xtrabackup.sh --target-dir=/tmp/xtrabackups/$(date +%F_%H-%M-%S) --backup 2>&1 | tee /tmp/xtrabackup.log') do |r| + expect(r.exit_code).to be_zero + end + end + + it 'xtrabackup reports success for the backup' do + # NOTE: Once support for CentOS 6 is dropped, we should check for "completed OK" instead. + run_shell('grep "xtrabackup: Transaction log of lsn" /tmp/xtrabackup.log') do |r| + expect(r.exit_code).to be_zero + end + end + end + # rubocop:enable RSpec/MultipleExpectations, RSpec/ExampleLength +end diff --git a/spec/acceptance/types/mysql_login_path_spec.rb b/spec/acceptance/types/mysql_login_path_spec.rb new file mode 100644 index 000000000..f07aed45c --- /dev/null +++ b/spec/acceptance/types/mysql_login_path_spec.rb @@ -0,0 +1,266 @@ +require 'spec_helper_acceptance' + +mysql_version = '5.6' +support_bin_dir = '/root/mysql_login_path' +if os[:family] == 'redhat' && os[:release].to_i == 8 + mysql_version = '8.0' +elsif os[:family] == 'debian' && os[:release] =~ %r{9|10} + mysql_version = '8.0' +elsif os[:family] == 'ubuntu' && os[:release] =~ %r{16\.04|18\.04} + mysql_version = '5.7' +end + +describe 'mysql_login_path', unless: ("#{os[:family]}-#{os[:release].to_i}" =~ %r{redhat\-5|suse}) do + before(:all) do + run_shell("rm -rf #{support_bin_dir}") + bolt_upload_file('spec/support/mysql_login_path', support_bin_dir) + run_shell("cp #{support_bin_dir}/mysql-#{mysql_version}/my_print_defaults /usr/bin/.") + run_shell("cp #{support_bin_dir}/mysql-#{mysql_version}/mysql_config_editor /usr/bin/.") + end + + after(:all) do + pp_cleanup = <<-MANIFEST + user { 'loginpath_test': + ensure => absent, + } + file { '/root/.mylogin.cnf': + ensure => absent, + } + MANIFEST + apply_manifest(pp_cleanup, catch_failures: true) + run_shell("rm -rf #{support_bin_dir}") + end + + describe 'setup' do + pp = <<-MANIFEST + if versioncmp($::puppetversion, '6.0.0') < 0 { + include resource_api + } + user { 'loginpath_test': + ensure => present, + managehome => true, + } + MANIFEST + it 'works with no errors' do + apply_manifest(pp, catch_failures: true) + end + it 'finds mysql_config_editor binary for the provider' do + run_shell('mysql_config_editor -V') do |r| + expect(r.stdout).to match(%r{Ver.*#{mysql_version}.*x86_64}) + end + end + it 'finds my_print_defaults binary for the provider' do + run_shell('my_print_defaults -V') do |r| + expect(r.exit_status).to eq(0) + end + end + end + + context 'for user root' do + describe 'add login path' do + pp = <<-MANIFEST + mysql_login_path { 'local_socket': + owner => root, + host => 'localhost', + user => 'root', + password => Sensitive('secure'), + socket => '/var/run/mysql/mysql.sock', + ensure => present, + } + mysql_login_path { 'local_tcp': + owner => root, + host => '127.0.0.1', + user => 'network', + password => Sensitive('more_secure'), + port => 3306, + ensure => present, + } + MANIFEST + it 'works without errors' do + apply_manifest(pp, catch_failures: true) + end + it 'finds the login path #stdout' do + run_shell('mysql_config_editor print --all') do |r| + expect(r.stdout).to match(%r{^\[local_socket\]\n}) + expect(r.stdout).to match(%r{host = localhost\n}) + expect(r.stdout).to match(%r{user = root\n}) + expect(r.stdout).to match(%r{socket = /var/run/mysql/mysql.sock\n}) + + expect(r.stdout).to match(%r{^\[local_tcp\]\n}) + expect(r.stdout).to match(%r{host = 127.0.0.1\n}) + expect(r.stdout).to match(%r{user = network\n}) + expect(r.stdout).to match(%r{port = 3306\n}) + expect(r.stderr).to be_empty + end + end + it 'finds the login path password #stdout' do + run_shell('my_print_defaults -s local_socket') do |r| + expect(r.stdout).to match(%r{--password=secure\n}) + end + run_shell('my_print_defaults -s local_tcp') do |r| + expect(r.stdout).to match(%r{--password=more_secure\n}) + end + end + end + + describe 'update login path' do + pp = <<-MANIFEST + mysql_login_path { 'local_tcp-root': + owner => root, + host => '10.0.0.1', + user => 'network2', + password => Sensitive('Fort_kn0X'), + port => 3307, + ensure => present, + } + MANIFEST + pp2 = <<-MANIFEST + mysql_login_path { 'local_tcp-root': + ensure => present, + host => '192.168.0.1' + } + MANIFEST + it 'works without errors' do + apply_manifest(pp, catch_failures: true) + end + it 'finds the login path #stdout' do + run_shell('mysql_config_editor print -G local_tcp') do |r| + expect(r.stdout).to match(%r{^\[local_tcp\]\n}) + expect(r.stdout).to match(%r{host = 10.0.0.1\n}) + expect(r.stdout).to match(%r{user = network2\n}) + expect(r.stdout).to match(%r{port = 3307\n}) + expect(r.stderr).to be_empty + end + end + it 'finds the login path password #stdout' do + run_shell('my_print_defaults -s local_tcp') do |r| + expect(r.stdout).to match(%r{--password=Fort_kn0X\n}) + end + end + + it 'applies idempotent' do + idempotent_apply(pp) + end + + it 'removes values' do + apply_manifest(pp2, catch_failures: true) + end + it 'ensure values are removed #stdout' do + run_shell('mysql_config_editor print -G local_tcp') do |r| + expect(r.stdout).to match(%r{^\[local_tcp\]\n}) + expect(r.stdout).to match(%r{host = 192.168.0.1\n}) + expect(r.stdout).not_to match(%r{host = 10.0.0.1\n}) + expect(r.stdout).not_to match(%r{user = network2\n}) + expect(r.stdout).not_to match(%r{port = 3307\n}) + expect(r.stderr).to be_empty + end + end + it 'ensure password removed from the login path #stdout' do + run_shell('my_print_defaults -s local_tcp') do |r| + expect(r.stdout).not_to match(%r{--password=Fort_kn0X\n}) + end + end + end + + describe 'delete login path' do + pp = <<-MANIFEST + mysql_login_path { 'local_socket': + owner => root, + ensure => absent, + } + mysql_login_path { 'local_tcp-root': + ensure => absent, + } + MANIFEST + it 'works without errors' do + apply_manifest(pp, catch_failures: true) + end + it 'finds the login path #stdout' do + run_shell('mysql_config_editor print --all') do |r| + expect(r.stdout).not_to match(%r{^\[local_socket\]\n}) + expect(r.stdout).not_to match(%r{^\[local_tcp\]\n}) + expect(r.stderr).to be_empty + end + end + end + end + + context 'for user loginpath_test' do + describe 'add login path' do + pp = <<-MANIFEST + mysql_login_path { 'local_tcp': + owner => loginpath_test, + host => '10.0.0.2', + user => 'other', + password => Sensitive('sensitive'), + port => 3306, + ensure => present, + } + MANIFEST + it 'works without errors' do + apply_manifest(pp, catch_failures: true) + end + it 'finds the login path #stdout' do + run_shell('MYSQL_TEST_LOGIN_FILE=/home/loginpath_test/.mylogin.cnf mysql_config_editor print -G local_tcp') do |r| + expect(r.stdout).to match(%r{^\[local_tcp\]\n}) + expect(r.stdout).to match(%r{host = 10.0.0.2\n}) + expect(r.stdout).to match(%r{user = other\n}) + expect(r.stdout).to match(%r{port = 3306\n}) + expect(r.stderr).to be_empty + end + end + it 'finds the login path password #stdout' do + run_shell('MYSQL_TEST_LOGIN_FILE=/home/loginpath_test/.mylogin.cnf my_print_defaults print -s local_tcp') do |r| + expect(r.stdout).to match(%r{--password=sensitive\n}) + end + end + end + + describe 'update login path' do + pp = <<-MANIFEST + mysql_login_path { 'local_tcp-loginpath_test': + host => '10.0.0.3', + user => 'other2', + password => Sensitive('password'), + port => 3307, + ensure => present, + } + MANIFEST + it 'works without errors' do + apply_manifest(pp, catch_failures: true) + end + it 'finds the login path #stdout' do + run_shell('MYSQL_TEST_LOGIN_FILE=/home/loginpath_test/.mylogin.cnf mysql_config_editor print -G local_tcp') do |r| + expect(r.stdout).to match(%r{^\[local_tcp\]\n}) + expect(r.stdout).to match(%r{host = 10.0.0.3\n}) + expect(r.stdout).to match(%r{user = other2\n}) + expect(r.stdout).to match(%r{port = 3307\n}) + expect(r.stderr).to be_empty + end + end + it 'finds the login path password #stdout' do + run_shell('MYSQL_TEST_LOGIN_FILE=/home/loginpath_test/.mylogin.cnf my_print_defaults -s local_tcp') do |r| + expect(r.stdout).to match(%r{--password=password\n}) + end + end + end + + describe 'delete login path' do + pp = <<-MANIFEST + mysql_login_path { 'local_tcp': + owner => loginpath_test, + ensure => absent, + } + MANIFEST + it 'works without errors' do + apply_manifest(pp, catch_failures: true) + end + it 'finds the login path #stdout' do + run_shell('MYSQL_TEST_LOGIN_FILE=/home/loginpath_test/.mylogin.cnf mysql_config_editor print --all') do |r| + expect(r.stdout).not_to match(%r{^\[local_tcp\]\n}) + expect(r.stderr).to be_empty + end + end + end + end +end diff --git a/spec/classes/mysql_backup_xtrabackup_spec.rb b/spec/classes/mysql_backup_xtrabackup_spec.rb index c1e779a2a..a4daa2fba 100644 --- a/spec/classes/mysql_backup_xtrabackup_spec.rb +++ b/spec/classes/mysql_backup_xtrabackup_spec.rb @@ -27,30 +27,58 @@ class { 'mysql::server': } ) end + package = if facts[:osfamily] == 'RedHat' + if Puppet::Util::Package.versioncmp(facts[:operatingsystemmajrelease], '8') >= 0 + 'percona-xtrabackup-24' + elsif Puppet::Util::Package.versioncmp(facts[:operatingsystemmajrelease], '7') >= 0 + 'percona-xtrabackup' + else + 'percona-xtrabackup-20' + end + elsif facts[:operatingsystem] == 'Debian' + 'percona-xtrabackup-24' + elsif facts[:operatingsystem] == 'Ubuntu' + if Puppet::Util::Package.versioncmp(facts[:operatingsystemmajrelease], '16') >= 0 + 'percona-xtrabackup' + else + 'percona-xtrabackup-24' + end + elsif facts[:osfamily] == 'Suse' + 'xtrabackup' + else + 'percona-xtrabackup' + end + it 'contains the weekly cronjob' do is_expected.to contain_cron('xtrabackup-weekly') .with( ensure: 'present', - command: '/usr/local/sbin/xtrabackup.sh --target-dir=/tmp --backup', + command: '/usr/local/sbin/xtrabackup.sh --target-dir=/tmp/$(date +\%F)_full --backup', user: 'root', hour: '23', minute: '5', weekday: '0', ) - .that_requires('Package[percona-xtrabackup]') + .that_requires("Package[#{package}]") end it 'contains the daily cronjob for weekdays 1-6' do + dateformat = case facts[:osfamily] + when 'FreeBSD', 'OpenBSD' + '$(date -v-sun +\%F)_full' + else + '$(date -d "last sunday" +\%F)_full' + end is_expected.to contain_cron('xtrabackup-daily') .with( ensure: 'present', - command: '/usr/local/sbin/xtrabackup.sh --incremental-basedir=/tmp --target-dir=/tmp/$(date +\%F_\%H-\%M-\%S) --backup', + command: "/usr/local/sbin/xtrabackup.sh --incremental-basedir=/tmp/#{dateformat} --target-dir=/tmp/$(date +\\\%F_\\\%H-\\\%M-\\\%S) --backup", user: 'root', hour: '23', minute: '5', weekday: '1-6', ) - .that_requires('Package[percona-xtrabackup]') + .that_requires("Package[#{package}]") end end @@ -84,30 +112,59 @@ class { 'mysql::server': } { additional_cron_args: '--backup --skip-ssl' }.merge(default_params) end + package = if facts[:osfamily] == 'RedHat' + if Puppet::Util::Package.versioncmp(facts[:operatingsystemmajrelease], '8') >= 0 + 'percona-xtrabackup-24' + elsif Puppet::Util::Package.versioncmp(facts[:operatingsystemmajrelease], '7') >= 0 + 'percona-xtrabackup' + else + 'percona-xtrabackup-20' + end + elsif facts[:operatingsystem] == 'Debian' + 'percona-xtrabackup-24' + elsif facts[:operatingsystem] == 'Ubuntu' + if Puppet::Util::Package.versioncmp(facts[:operatingsystemmajrelease], '16') >= 0 + 'percona-xtrabackup' + else + 'percona-xtrabackup-24' + end + elsif facts[:osfamily] == 'Suse' + 'xtrabackup' + else + 'percona-xtrabackup' + end + + dateformat = case facts[:osfamily] + when 'FreeBSD', 'OpenBSD' + '$(date -v-sun +\%F)_full' + else + '$(date -d "last sunday" +\%F)_full' + end + it 'contains the weekly cronjob' do is_expected.to contain_cron('xtrabackup-weekly') .with( ensure: 'present', - command: '/usr/local/sbin/xtrabackup.sh --target-dir=/tmp --backup --skip-ssl', + command: '/usr/local/sbin/xtrabackup.sh --target-dir=/tmp/$(date +\%F)_full --backup --skip-ssl', user: 'root', hour: '23', minute: '5', weekday: '0', ) - .that_requires('Package[percona-xtrabackup]') + .that_requires("Package[#{package}]") end it 'contains the daily cronjob for weekdays 1-6' do is_expected.to contain_cron('xtrabackup-daily') .with( ensure: 'present', - command: '/usr/local/sbin/xtrabackup.sh --incremental-basedir=/tmp --target-dir=/tmp/$(date +\%F_\%H-\%M-\%S) --backup --skip-ssl', + command: "/usr/local/sbin/xtrabackup.sh --incremental-basedir=/tmp/#{dateformat} --target-dir=/tmp/$(date +\\\%F_\\\%H-\\\%M-\\\%S) --backup --skip-ssl", user: 'root', hour: '23', minute: '5', weekday: '1-6', ) - .that_requires('Package[percona-xtrabackup]') + .that_requires("Package[#{package}]") end end @@ -123,7 +180,7 @@ class { 'mysql::server': } it 'contains the daily cronjob with all weekdays' do is_expected.to contain_cron('xtrabackup-daily').with( ensure: 'present', - command: '/usr/local/sbin/xtrabackup.sh --target-dir=/tmp --backup', + command: '/usr/local/sbin/xtrabackup.sh --target-dir=/tmp/$(date +\%F_\%H-\%M-\%S) --backup', user: 'root', hour: '23', minute: '5', diff --git a/spec/functions/mysql_mysql_password_spec.rb b/spec/functions/mysql_mysql_password_spec.rb deleted file mode 100644 index 21e49fdc4..000000000 --- a/spec/functions/mysql_mysql_password_spec.rb +++ /dev/null @@ -1,40 +0,0 @@ -require 'spec_helper' - -describe 'mysql::mysql_password' do - # without knowing details about the implementation, this is the only test - # case that we can autogenerate. You should add more examples below! - it { is_expected.not_to eq(nil) } - - ################################# - # Below are some example test cases. You may uncomment and modify them to match - # your needs. Notice that they all expect the base error class of `StandardError`. - # This is because the autogenerated function uses an untyped array for parameters - # and relies on your implementation to do the validation. As you convert your - # function to proper dispatches and typed signatures, you should change the - # expected error of the argument validation examples to `ArgumentError`. - # - # Other error types you might encounter include - # - # * StandardError - # * ArgumentError - # * Puppet::ParseError - # - # Read more about writing function unit tests at https://rspec-puppet.com/documentation/functions/ - # - # it 'raises an error if called with no argument' do - # is_expected.to run.with_params.and_raise_error(StandardError) - # end - # - # it 'raises an error if there is more than 1 arguments' do - # is_expected.to run.with_params({ 'foo' => 1 }, 'bar' => 2).and_raise_error(StandardError) - # end - # - # it 'raises an error if argument is not the proper type' do - # is_expected.to run.with_params('foo').and_raise_error(StandardError) - # end - # - # it 'returns the proper output' do - # is_expected.to run.with_params(123).and_return('the expected output') - # end - ################################# -end diff --git a/spec/functions/mysql_password_spec.rb b/spec/functions/mysql_password_spec.rb index ef90399d4..a1dfffcb4 100644 --- a/spec/functions/mysql_password_spec.rb +++ b/spec/functions/mysql_password_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'mysql::password' do +shared_examples 'mysql::password function' do it 'exists' do is_expected.not_to eq(nil) end @@ -29,3 +29,13 @@ is_expected.to run.with_params('*2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19').and_return('*2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19') end end + +describe 'mysql::password' do + it_behaves_like 'mysql::password function' + + describe 'non-namespaced shim' do + describe 'mysql_password', type: :puppet_function do + it_behaves_like 'mysql::password function' + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 1fbf08018..4ee263f9e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -38,6 +38,7 @@ # set to strictest setting for testing # by default Puppet runs at warning level Puppet.settings[:strict] = :warning + Puppet.settings[:strict_variables] = true end c.filter_run_excluding(bolt: true) unless ENV['GEM_BOLT'] c.after(:suite) do diff --git a/spec/spec_helper_acceptance.rb b/spec/spec_helper_acceptance.rb index aabeb0b80..4ac8d7e0f 100644 --- a/spec/spec_helper_acceptance.rb +++ b/spec/spec_helper_acceptance.rb @@ -1,82 +1,6 @@ # frozen_string_literal: true -require 'serverspec' require 'puppet_litmus' require 'spec_helper_acceptance_local' if File.file?(File.join(File.dirname(__FILE__), 'spec_helper_acceptance_local.rb')) -include PuppetLitmus -if ENV['TARGET_HOST'].nil? || ENV['TARGET_HOST'] == 'localhost' - puts 'Running tests against this machine !' - if Gem.win_platform? - set :backend, :cmd - else - set :backend, :exec - end -else - # load inventory - inventory_hash = inventory_hash_from_inventory_file - node_config = config_from_node(inventory_hash, ENV['TARGET_HOST']) - - if target_in_group(inventory_hash, ENV['TARGET_HOST'], 'docker_nodes') - host = ENV['TARGET_HOST'] - set :backend, :docker - set :docker_container, host - elsif target_in_group(inventory_hash, ENV['TARGET_HOST'], 'ssh_nodes') - set :backend, :ssh - options = Net::SSH::Config.for(host) - options[:user] = node_config.dig('ssh', 'user') unless node_config.dig('ssh', 'user').nil? - options[:port] = node_config.dig('ssh', 'port') unless node_config.dig('ssh', 'port').nil? - options[:keys] = node_config.dig('ssh', 'private-key') unless node_config.dig('ssh', 'private-key').nil? - options[:password] = node_config.dig('ssh', 'password') unless node_config.dig('ssh', 'password').nil? - # Support both net-ssh 4 and 5. - # rubocop:disable Metrics/BlockNesting - options[:verify_host_key] = if node_config.dig('ssh', 'host-key-check').nil? - # Fall back to SSH behavior. This variable will only be set in net-ssh 5.3+. - if @strict_host_key_checking.nil? || @strict_host_key_checking - Net::SSH::Verifiers::Always.new - else - # SSH's behavior with StrictHostKeyChecking=no: adds new keys to known_hosts. - # If known_hosts points to /dev/null, then equivalent to :never where it - # accepts any key beacuse they're all new. - Net::SSH::Verifiers::AcceptNewOrLocalTunnel.new - end - elsif node_config.dig('ssh', 'host-key-check') - if defined?(Net::SSH::Verifiers::Always) - Net::SSH::Verifiers::Always.new - else - Net::SSH::Verifiers::Secure.new - end - elsif defined?(Net::SSH::Verifiers::Never) - Net::SSH::Verifiers::Never.new - else - Net::SSH::Verifiers::Null.new - end - # rubocop:enable Metrics/BlockNesting - host = if ENV['TARGET_HOST'].include?(':') - ENV['TARGET_HOST'].split(':').first - else - ENV['TARGET_HOST'] - end - set :host, options[:host_name] || host - set :ssh_options, options - set :request_pty, true - elsif target_in_group(inventory_hash, ENV['TARGET_HOST'], 'winrm_nodes') - require 'winrm' - - set :backend, :winrm - set :os, family: 'windows' - user = node_config.dig('winrm', 'user') unless node_config.dig('winrm', 'user').nil? - pass = node_config.dig('winrm', 'password') unless node_config.dig('winrm', 'password').nil? - endpoint = "http://#{ENV['TARGET_HOST']}:5985/wsman" - - opts = { - user: user, - password: pass, - endpoint: endpoint, - operation_timeout: 300, - } - - winrm = WinRM::Connection.new opts - Specinfra.configuration.winrm = winrm - end -end +PuppetLitmus.configure! diff --git a/spec/spec_helper_acceptance_local.rb b/spec/spec_helper_acceptance_local.rb index 5e9d12740..376b413c1 100644 --- a/spec/spec_helper_acceptance_local.rb +++ b/spec/spec_helper_acceptance_local.rb @@ -1,14 +1,21 @@ # frozen_string_literal: true +require 'singleton' + +class LitmusHelper + include Singleton + include PuppetLitmus +end + def pre_run - apply_manifest("class { 'mysql::server': root_password => 'password' }", catch_failures: true) + LitmusHelper.instance.apply_manifest("class { 'mysql::server': root_password => 'password' }", catch_failures: true) end def mysql_version - shell_output = run_shell('mysql --version', expect_failures: true) + shell_output = LitmusHelper.instance.run_shell('mysql --version', expect_failures: true) if shell_output.stdout.match(%r{\d+\.\d+\.\d+}).nil? pre_run - shell_output = run_shell('mysql --version') + shell_output = LitmusHelper.instance.run_shell('mysql --version') raise _('unable to get mysql version') if shell_output.stdout.match(%r{\d+\.\d+\.\d+}).nil? end mysql_version = shell_output.stdout.match(%r{\d+\.\d+\.\d+})[0] @@ -19,9 +26,9 @@ def mysql_version c.before :suite do if os[:family] == 'debian' || os[:family] == 'ubuntu' # needed for the puppet fact - apply_manifest("package { 'lsb-release': ensure => installed, }", expect_failures: false) + LitmusHelper.instance.apply_manifest("package { 'lsb-release': ensure => installed, }", expect_failures: false) end # needed for the grant tests, not installed on el7 docker images - apply_manifest("package { 'which': ensure => installed, }", expect_failures: false) + LitmusHelper.instance.apply_manifest("package { 'which': ensure => installed, }", expect_failures: false) end end diff --git a/spec/support/mysql_login_path/mysql-5.6/my_print_defaults b/spec/support/mysql_login_path/mysql-5.6/my_print_defaults new file mode 100755 index 000000000..0ccc5acf9 Binary files /dev/null and b/spec/support/mysql_login_path/mysql-5.6/my_print_defaults differ diff --git a/spec/support/mysql_login_path/mysql-5.6/mysql_config_editor b/spec/support/mysql_login_path/mysql-5.6/mysql_config_editor new file mode 100755 index 000000000..6df452bcb Binary files /dev/null and b/spec/support/mysql_login_path/mysql-5.6/mysql_config_editor differ diff --git a/spec/support/mysql_login_path/mysql-5.7/my_print_defaults b/spec/support/mysql_login_path/mysql-5.7/my_print_defaults new file mode 100755 index 000000000..74749f587 Binary files /dev/null and b/spec/support/mysql_login_path/mysql-5.7/my_print_defaults differ diff --git a/spec/support/mysql_login_path/mysql-5.7/mysql_config_editor b/spec/support/mysql_login_path/mysql-5.7/mysql_config_editor new file mode 100755 index 000000000..38645a2a7 Binary files /dev/null and b/spec/support/mysql_login_path/mysql-5.7/mysql_config_editor differ diff --git a/spec/support/mysql_login_path/mysql-8.0/my_print_defaults b/spec/support/mysql_login_path/mysql-8.0/my_print_defaults new file mode 100755 index 000000000..e6650bdae Binary files /dev/null and b/spec/support/mysql_login_path/mysql-8.0/my_print_defaults differ diff --git a/spec/support/mysql_login_path/mysql-8.0/mysql_config_editor b/spec/support/mysql_login_path/mysql-8.0/mysql_config_editor new file mode 100755 index 000000000..e64fcacd6 Binary files /dev/null and b/spec/support/mysql_login_path/mysql-8.0/mysql_config_editor differ diff --git a/spec/unit/puppet/functions/mysql_password_spec.rb b/spec/unit/puppet/functions/mysql_password_spec.rb deleted file mode 100644 index 85c4d44a2..000000000 --- a/spec/unit/puppet/functions/mysql_password_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -require 'spec_helper' - -describe 'the mysql_password function' do - before :all do # rubocop:disable RSpec/BeforeAfterAll - Puppet::Parser::Functions.autoloader.loadall - end - - let(:scope) { PuppetlabsSpec::PuppetInternals.scope } - - it 'exists' do - expect(Puppet::Parser::Functions.function('mysql_password')).to eq('function_mysql_password') - end - - it 'raises a ParseError if there is less than 1 arguments' do - expect { scope.function_mysql_password([]) }.to(raise_error(Puppet::ParseError)) - end - - it 'raises a ParseError if there is more than 1 arguments' do - expect { scope.function_mysql_password(['foo', 'bar']) }.to(raise_error(Puppet::ParseError)) - end - - it 'converts password into a hash' do - result = scope.function_mysql_password(['password']) - expect(result).to(eq('*2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19')) - end - - it 'converts an empty password into a empty string' do - result = scope.function_mysql_password(['']) - expect(result).to(eq('')) - end - - it 'does not convert a password that is already a hash' do - result = scope.function_mysql_password(['*2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19']) - expect(result).to(eq('*2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19')) - end -end diff --git a/spec/unit/puppet/provider/mysql_login_path/mysql_login_path_spec.rb b/spec/unit/puppet/provider/mysql_login_path/mysql_login_path_spec.rb new file mode 100644 index 000000000..5000c8f74 --- /dev/null +++ b/spec/unit/puppet/provider/mysql_login_path/mysql_login_path_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' + +ensure_module_defined('Puppet::Provider::MysqlLoginPath') +require 'puppet/provider/mysql_login_path/mysql_login_path' + +RSpec.describe Puppet::Provider::MysqlLoginPath::MysqlLoginPath do + subject(:provider) { described_class.new } + + let(:context) { mock('Puppet::ResourceApi::BaseContext') } + let(:wait_thr) { mock('wait_thr') } + let(:wait_thr_value) { mock('wait_thr_value') } + let(:sensitive_secure) { Puppet::Provider::MysqlLoginPath::Sensitive.new('secure') } + let(:sensitive_more_secure) { Puppet::Provider::MysqlLoginPath::Sensitive.new('more_secure') } + + before :each do + Puppet::Util::Execution.stubs(:execute).with(['/usr/bin/getent', 'passwd', 'root'], failonfail: true).returns('root:x:0:0:root:/root:/bin/bash') + + Puppet::Util::Execution.stubs(:execute).with(['/usr/bin/mysql_config_editor', 'print', '--all'], failonfail: true, uid: 'root', custom_environment: { 'HOME' => '/root' }) + .returns("[local_tcp]\nuser = root\npassword = *****\nhost = 127.0.0.1\nport = 3306") + Puppet::Util::Execution.stubs(:execute).with(['/usr/bin/mysql_config_editor', 'remove', '-G', 'local_socket'], failonfail: true, uid: 'root', custom_environment: { 'HOME' => '/root' }) + + Puppet::Util::Execution.stubs(:execute).with(['/usr/bin/my_print_defaults', '-s', 'local_tcp'], failonfail: true, uid: 'root', custom_environment: { 'HOME' => '/root' }) + .returns("--user=root\n--password=secure\n--host=127.0.0.1\n--port=3306") + Puppet::Util::Execution.stubs(:execute).with(['/usr/bin/my_print_defaults', '-s', 'local_socket'], failonfail: true, uid: 'root', custom_environment: { 'HOME' => '/root' }) + .returns("--user=root\n--password=more_secure\n--host=localhost\n--socket=/var/run/mysql.sock") + + wait_thr_value.stubs(:success?).returns(true) + wait_thr.stubs(:value).returns(wait_thr_value) + Open3.stubs(:popen3) + .with({ 'HOME' => '/root' }, + '/usr/bin/mysql_config_editor set --skip-warn -G local_socket -h localhost -u root ' \ + '-S /var/run/mysql/mysql.sock -p') + .returns(wait_thr_value) + + Open3.stubs(:popen3) + .with({ 'HOME' => '/root' }, + '/usr/bin/mysql_config_editor set --skip-warn -G local_socket -h 127.0.0.1 -u root -P 3306 -p') + .returns(wait_thr_value) + end + + describe '#get' do + it 'processes resources' do + expect(provider.get(context, [{ owner: 'root' }])).to eq [ + { + ensure: 'present', + host: '127.0.0.1', + name: 'local_tcp', + owner: 'root', + password: sensitive_secure, + port: 3306, + socket: nil, + title: 'local_tcp-root', + user: 'root', + }, + ] + end + end + + describe 'create(context, name, should)' do + it 'creates the resource' do + provider.create(context, { name: 'local_socket', owner: 'root' }, + name: 'local_socket', + owner: 'root', + host: 'localhost', + user: 'root', + password: sensitive_more_secure, + socket: '/var/run/mysql/mysql.sock', + ensure: 'present') + end + end + + describe 'update(context, name, should)' do + it 'updates the resource' do + provider.update(context, { name: 'local_socket', owner: 'root' }, + name: 'local_socket', + owner: 'root', + host: '127.0.0.1', + user: 'root', + password: sensitive_more_secure, + port: 3306, + ensure: 'present') + end + end + + describe 'delete(context, name)' do + it 'deletes the resource' do + provider.delete(context, name: 'local_socket', owner: 'root') + end + end +end diff --git a/spec/unit/puppet/type/mysql_login_path_spec.rb b/spec/unit/puppet/type/mysql_login_path_spec.rb new file mode 100644 index 000000000..b36bdfec6 --- /dev/null +++ b/spec/unit/puppet/type/mysql_login_path_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'puppet' +require 'puppet/type/mysql_login_path' + +describe Puppet::Type.type(:mysql_login_path) do + it 'loads' do + expect(Puppet::Type.type(:mysql_login_path)).not_to be_nil + end + + it 'requires a name' do + expect { + Puppet::Type.type(:mysql_login_path).new({}) + }.to raise_error(Puppet::Error, 'Title or name must be provided') + end + + context 'using login path with socket' do + let(:login_path) do + Puppet::Type.type(:mysql_login_path).new( + name: 'local_socket', + host: 'localhost', + user: 'root', + password: Puppet::Pops::Types::PSensitiveType::Sensitive.new('secure'), + socket: '/var/run/mysql/mysql.sock', + ) + end + + it 'accepts a name' do + login_path[:name] = 'local_socket' + expect(login_path[:name]).to eq('local_socket') + end + + it 'accepts a host' do + login_path[:host] = '10.0.0.1' + expect(login_path[:host]).to eq('10.0.0.1') + end + + it 'accepts a user' do + login_path[:user] = 'user1' + expect(login_path[:user]).to eq('user1') + end + + it 'accepts a password' do + login_path[:password] = Puppet::Pops::Types::PSensitiveType::Sensitive.new('even_more_secure') + expect(login_path[:password].unwrap).to eq('even_more_secure') + end + end + + context 'using login path with tcp' do + let(:login_path) do + Puppet::Type.type(:mysql_login_path).new( + name: 'local_tcp', + host: '127.0.0.1', + user: 'root', + password: Puppet::Pops::Types::PSensitiveType::Sensitive.new('secure'), + port: 3306, + ) + end + + it 'accepts a port' do + login_path[:port] = 3307 + expect(login_path[:port]).to eq(3307) + end + end +end diff --git a/templates/xtrabackup.sh.erb b/templates/xtrabackup.sh.erb index c12cd07ad..0f7a98ccc 100644 --- a/templates/xtrabackup.sh.erb +++ b/templates/xtrabackup.sh.erb @@ -26,9 +26,9 @@ set -o pipefail cleanup() { <%- if @kernel == 'SunOS' -%> - gfind "${DIR}/" -maxdepth 1 -type f -name "${PREFIX}*.sql*" -mtime +${ROTATE} -print0 | gxargs -0 -r rm -f + gfind "${DIR}/" -mindepth 1 -maxdepth 1 -mtime +${ROTATE} -print0 | gxargs -0 -r rm -rf <%- else -%> - find "${DIR}/" -maxdepth 1 -type f -name "${PREFIX}*.sql*" -mtime +${ROTATE} -print0 | xargs -0 -r rm -f + find "${DIR}/" -mindepth 1 -maxdepth 1 -mtime +${ROTATE} -print0 | xargs -0 -r rm -rf <%- end -%> }