All Downloads are FREE. Search and download functionalities are using the official Maven repository.

pallet.crate.postgres.clj Maven / Gradle / Ivy

(ns pallet.crate.postgres
  "Install and configure PostgreSQL."
  (:require
   [pallet.action :as action]
   [pallet.action.directory :as directory]
   [pallet.action.exec-script :as exec-script]
   [pallet.action.file :as file]
   [pallet.action.package :as package]
   [pallet.action.package.debian-backports :as debian-backports]
   [pallet.action.remote-file :as remote-file]
   [pallet.action.service :as service]
   [pallet.parameter :as parameter]
   [pallet.script.lib :as lib]
   [pallet.session :as session]
   [pallet.stevedore :as stevedore]
   [pallet.thread-expr :as thread-expr]
   [clojure.contrib.condition :as condition]
   [clojure.contrib.logging :as logging]
   [clojure.string :as string])
  (:use
   pallet.thread-expr
   [pallet.script :only [defscript]]))

(def ^{:doc "Flag for recognising changes to configuration"}
  postgresql-config-changed-flag "postgresql-config")

(def default-settings-map
  {:version "9.0"
   :components #{:server :contrib}
   :port 5432
   :max_connections 100
   :ssl true
   :shared_buffers "24MB"
   :log_line_prefix "%t "
   :datestyle "iso, ymd"
   :default_text_search_config "pg_catalog.english"})

(defn settings-map
  "Build a settings map for postgresql.
      :version     postgresql version to install
      :components  postgresql components to install
      :permissions permissions to set in hba

   Unrecognised options will be added to the main configuration file."
  [{:keys [version components data_directory hba_file ident_file
           external_pid_file port max_connections unix_socket_directory
           ssl shared_buffers log_line_prefix datestyle
           default_text_search_config]
    :as options}]
  (merge default-settings-map options))

(defn package-source
  "Decide where to get the packages from"
  [session version]
  (let [os-family (session/os-family session)]
    (cond
     (and (= :debian os-family) (= "9.0" version)) :debian-backports
     (and (= :ubuntu os-family) (= "9.0" version)) :martin-pitt-backports
     (and (= :centos os-family) (= "9.0" version)) :pgdg
     (and (= :fedora os-family) (= "9.0" version)) :pgdg
     :else :native)))

(def pgdg-repo-versions
  {"9.0" "9.0-2"})

(defmulti default-settings
  "Determine the default settings for the specified "
  (fn [session os-family package-source settings]
    [os-family package-source]))

(defn base-settings [session]
  {:service "postgresql"
   :owner "postgres"
   :external_pid_file (str (stevedore/script (~lib/pid-root)) "/postgresql.pid")
   :unix_socket_directory (str (stevedore/script (~lib/pid-root))
                               "/postgresql")
   :initdb-via :service})

(defmethod default-settings [:debian :native]
  [session os-family package-source settings]
  (let [version (:version settings)]
    (merge
     (base-settings session)
     {:packages ["postgresql"]
      :data_directory (format "/var/lib/postgresql/%s/main" version)
      :postgresql_file (format
                        "/etc/postgresql/%s/main/postgresql.conf" version)
      :hba_file (format "/etc/postgresql/%s/main/pg_hba.conf" version)
      :ident_file (format "/etc/postgresql/%s/main/pg_ident.conf" version)
      :external_pid_file (format "/var/run/postgresql/%s-main.pid" version)
      :unix_socket_directory "/var/run/postgresql"})))

(defmethod default-settings [:rh :native]
  [session os-family package-source settings]
  (let [version (:version settings)]
    (merge
     (base-settings session)
     {:packages (map
                 #(str "postgresql-" (name %))
                 (:components settings #{:server :libs}))
      :data_directory (format "/var/lib/pgsql/%s/" version)
      :postgresql_file (format "/var/lib/pgsql/%s/postgresql.conf" version)
      :hba_file (format "/var/lib/pgsql/%s/pg_hba.conf" version)
      :ident_file (format "/var/lib/pgsql/%s/pg_ident.conf" version)
      :external_pid_file (format "/var/run/pgsql/%s.pid" version)
      :unix_socket_directory "/var/run/pgsql"})))

(defmethod default-settings [:rh :pgdg]
  [session os-family package-source settings]
  (let [version (:version settings)]
    (merge
     (base-settings session)
     (default-settings session :rh :native settings)
     {:packages (map
                 #(str "postgresql" (string/replace version "." "")
                       "-" (name %))
                 (:components settings))
      :data_directory (format "/var/lib/pgsql/%s" version)
      :postgresql_file (format "/var/lib/pgsql/%s/postgresql.conf" version)
      :hba_file (format "/var/lib/pgsql/%s/pg_hba.conf" version)
      :ident_file (format "/var/lib/pgsql/%s/pg_ident.conf" version)
      :external_pid_file (format "/var/run/pgsql/%s.pid" version)
      :service (str "postgresql-" version)})))

(defmethod default-settings [:arch :native]
  [session os-family package-source settings]
  (let [version (:version settings)]
    (merge
     (base-settings session)
     {:components []
      :packages ["postgresql"]
      :data_directory "/var/lib/postgres/data/"
      :postgresql_file  "/var/lib/postgres/data/postgresql.conf"
      :hba_file  "/var/lib/postgres/data/pg_hba.conf"
      :ident_file "/var/lib/postgres/data/pg_ident.conf"
      :initdb-via :initdb})))

(defmethod default-settings [:debian :debian-backports]
  [session os-family package-source settings]
  (let [version (:version settings)]
    (merge
     (default-settings session :debian :native settings)
     {:packages [(str "postgresql-" version)]})))

(defmethod default-settings [:debian :martin-pitt-backports]
  [session os-family package-source settings]
  (default-settings session :debian :debian-backports settings))

(def non-options-in-settings
  #{:components :version :permissions :initdb-via :package-source :packages
    :service :owner})

(defn settings
  "Build a settings for postgresql"
  [session settings-map]
  (let [os-family (session/os-family session)
        os-base (session/base-distribution session)
        components (:components settings-map)
        version (:version settings-map)
        package-source (package-source session version)
        settings (merge
                  (default-settings
                    session os-base package-source settings-map)
                  {:package-source package-source}
                  settings-map)]
    (parameter/assoc-for-target
     session
     [:postgresql]
     (assoc (select-keys settings non-options-in-settings)
       :options (apply dissoc settings non-options-in-settings)))))

(defn postgres
  "Version should be a string identifying the major.minor version number desired
   (e.g. \"9.0\")."
  ([session]
     (let [os-family (session/os-family session)
           settings (parameter/get-for-target session [:postgresql])
           packages (:packages settings)
           package-source (:package-source settings)
           version (:version settings)]
       (logging/info
        (format "postgresql %s from %s packages [%s]"
                version (name package-source) (string/join ", " packages)))
       (->
        session
        (when-> (= package-source :martin-pitt-backports)
                (package/package-source
                 "Martin Pitt backports"
                 :aptitude {:url "ppa:pitti/postgresql"})
                (package/package-manager :update))
        (when-> (= package-source :debian-backports)
                (debian-backports/add-debian-backports)
                (package/package-manager :update)
                (package/package
                 "libpq5"
                 :enable (str
                          (stevedore/script (~lib/os-version-name))
                          "-backports")))
        (when->
         (= package-source :pgdg)
         (action/with-precedence {:action-id ::add-pgdg-rpm
                                  :always-before `package/package}
           (package/add-rpm
            "pgdg.rpm"
            :url (format
                  "http://yum.pgrpms.org/reporpms/%s/pgdg-%s-%s.noarch.rpm"
                  version (name os-family) (pgdg-repo-versions version))))
         (action/with-precedence {:action-id ::pgdg-update
                                  :always-before `package/package
                                  :always-after ::add-pgdg-rpm}
           (package/package-manager :update)))
        ;; install packages
        (arg-> [session]
               (for-> [package (:packages settings)]
                      (package/package package))))))
  ;; this is to preserve API compatibility
  ([session version]
     (->
      session
      (settings
       (merge
        default-settings-map
        (parameter/get-for-target session [:postgresql] nil)
        {:version version}))
      (postgres))))


(def ^{:private true} pallet-cfg-preamble
"# This file was auto-generated by Pallet. Do not edit it manually unless you
# know what you are doing. If you are still using Pallet, you probably want to
# edit your Pallet scripts and rerun them.\n")

;;
;; pg_hba.conf
;;

(def ^{:private true}
  auth-methods #{"trust" "reject" "md5" "password" "gss" "sspi" "krb5"
                                     "ident" "ldap" "radius" "cert" "pam"})
(def ^{:private true}
  ip-addr-regex #"[0-9]{1,3}.[0-9]{1,3}+.[0-9]{1,3}+.[0-9]{1,3}+")

(defn- valid-hba-record?
  "Takes an hba-record as input and minimally checks that it could be a valid
   record."
  [{:keys [connection-type database user auth-method address ip-mask]
    :as record-map}]
  (and (#{"local" "host" "hostssl" "hostnossl"} (name connection-type))
       (every? #(not (nil? %)) [database user auth-method])
       (auth-methods (name auth-method))))

(defn- record-to-map
  "Takes a record given as a map or vector, and turns it into the map version."
  [record]
  (cond
   (map? record) record
   (vector? record) (case (name (first record))
                      "local" (apply
                               hash-map
                               (interleave
                                [:connection-type :database :user :auth-method
                                 :auth-options]
                                record))
                      ("host"
                       "hostssl"
                       "hostnossl") (let [[connection-type database user address
                                           & remainder] record]
                                      (if (re-matches
                                           ip-addr-regex (first remainder))
                                        ;; Not nil so must be an IP mask.
                                        (apply
                                         hash-map
                                         (interleave
                                          [:connection-type :database :user
                                           :address :ip-mask :auth-method
                                           :auth-options]
                                          record))
                                        ;; Otherwise, it may be an auth-method.
                                        (if (auth-methods
                                             (name (first remainder)))
                                          (apply
                                           hash-map
                                           (interleave
                                            [:connection-type :database :user
                                             :address :auth-method
                                             :auth-options]
                                            record))
                                          (condition/raise
                                           :type :postgres-invalid-hba-record
                                           :message
                                           (format
                                            "The fifth item in %s does not appear to be an IP mask or auth method."
                                            (pr-str record))))))
                       (condition/raise
                        :type :postgres-invalid-hba-record
                        :message (format
                                  "The first item in %s is not a valid connection type."
                                  (name record))))
   :else
   (condition/raise :type :postgres-invalid-hba-record
                    :message (format "The record %s must be a vector or map."
                                     (name record)))))

(defn- format-auth-options
  "Given the auth-options map, returns a string suitable for inserting into the
   file."
  [auth-options]
  (string/join "," (map #(str (first %) "=" (second %)) auth-options)))

(defn- format-hba-record
  [record]
  (let [record-map (record-to-map record)
        record-map (assoc record-map :auth-options
                          (format-auth-options (:auth-options record-map)))
        ordered-fields (map #(% record-map "")
                            [:connection-type :database :user :address :ip-mask
                             :auth-method :auth-options])
        ordered-fields (map name ordered-fields)]
    (if (valid-hba-record? record-map)
      (str (string/join "\t" ordered-fields) "\n"))))

(defn hba-conf
  "Generates a pg_hba.conf file from the arguments. Each record is either a
   vector or map of keywords/args.

   Note that pg_hba.conf is case-sensitive: all means all databases, ALL is a
   database named ALL.

   Also note that if you intend to execute subsequent commands, you'd do best to
   include entries in here that allow the admin user you are using easy access
   to the database. For example, allow the postgres user to have ident access
   over local.

   Options:
   :records     - A sequence of records (either vectors or maps of
                  keywords/strings).
   :conf-path   - A format string for the full file path, with a %s for the
                  version."
  [session & {:keys [records conf-path]}]
  (let [settings (parameter/get-for-target session [:postgresql] {})
        version (:version settings)
        records (or records (:permissions settings) [])
        conf-path (or
                   (when-let [d conf-path] (format d version))
                   (-> settings :options :hba_file))
        hba-contents (apply str pallet-cfg-preamble
                            (map format-hba-record records))]
    (-> session
        (remote-file/remote-file conf-path
         :content hba-contents
         :literal true
         :flag-on-changed postgresql-config-changed-flag
         :owner (:owner settings)))))

;;
;; postgresql.conf
;;

(defn- parameter-escape-string
  "Given a string, escapes any single-quotes."
  [string]
  (apply str (replace {\' "''"} string)))

(defn- format-parameter-value
  [value]
  (cond (number? value)
        (str value)
        (string? value)
        (str "'" value "'")
        (vector? value)
        (str "'" (string/join "," (map name value)) "'")
        (or (= value true) (= value false))
        (str value)
        :else
        (condition/raise
         :type :postgres-invalid-parameter
         :message (format
                   (str
                    "Parameters must be numbers, strings, or vectors of such. "
                    "Invalid value %s") (pr-str value))
         :value value)))

(defn- format-parameter
  "Given a key/value pair in a vector, formats it suitably for the
   postgresql.conf file.
   The value should be either a number, a string, or a vector of such."
  [[key value]]
  (let [key-str (name key)
        parameter-str (format-parameter-value value)]
    (str key-str " = " parameter-str "\n")))

(defn postgresql-conf
  "Generates a postgresql.conf file from the arguments.
   Example: (postgresql-conf
              :options {:listen_address [\"10.0.1.1\",\"localhost\"]})
         => listen_address = '10.0.1.1,localhost'

   Options:
   :options     - A map of parameters (string(able)s, numbers, or vectors of
                  such).
   :conf-path   - A format string for the file path, with a %s for
                  the version."
  [session & {:keys [options conf-path]}]
  (let [settings (parameter/get-for-target session [:postgresql] {})
        version (:version settings)
        conf-path (or
                   conf-path
                   (when-let [d conf-path] (format d version))
                   (-> settings :options :postgresql_file))
        options (or options (:options settings))
        contents (apply str pallet-cfg-preamble (map format-parameter options))]
    (remote-file/remote-file
     session conf-path :content contents :literal true :owner (:owner settings)
     :flag-on-changed postgresql-config-changed-flag)))

(declare service)

(defn initdb
  "Initialise a db"
  [session]
  (let [settings (parameter/get-for-target session [:postgresql] {})
        initdb-via (:initdb-via settings :initdb)
        data-dir (-> settings :options :data_directory)]
    (case initdb-via
          :service (service session :action :initdb)
          :initdb (->
                   session
                   (directory/directory
                    data-dir
                    :owner (:owner settings "postgres")
                    :mode "0755"
                    :path true)
                   (exec-script/exec-checked-script
                    "initdb"
                    (sudo -u ~(:owner settings "postgres")
                          initdb -D ~data-dir))))))

;;
;; Scripts
;;

(defn postgresql-script
  "Execute a postgresql script.

   The script is specified using remote-file content options (:content for
   a literal script)

   Options for how this script should be run:
     :as-user username       - Run this script having sudoed to this (system)
                               user. Default: postgres
     :ignore-result          - Ignore any error return value out of psql."
  [session & {:keys [as-user ignore-result] :as options}]
  (let [settings (parameter/get-for-target session [:postgresql] {})
        as-user (or as-user (-> settings :owner))
        file (str (gensym "postgresql") ".sql")]
    (-> session
        (apply-map->
         remote-file/remote-file
         file
         :no-versioning true
         (select-keys options remote-file/content-options))
        (exec-script/exec-script
         ;; Note that we stuff all output. This is because certain commands in
         ;; PostgreSQL are idempotent but spit out an error and an error exit
         ;; anyways (eg, create database on a database that already exists does
         ;; nothing, but is counted as an error).
         ("{\n" sudo "-u" ~as-user psql "-f" ~file > "/dev/null" "2>&1"
          ~(when ignore-result "|| true") "\n}"))
        (remote-file/remote-file file :action :delete))))

(defn create-database
  "Create a database if it does not exist.

   You can specify database parameters by including a keyed parameter called
   :db-parameters, which indicates a vector of strings or keywords that will get
   translated in order to the options to the create database command. Passes on
   key/value arguments it does not understand to postgresql-script.

   Example: (create-database
              \"my-database\" :db-parameters [:encoding \"'LATIN1'\"])"
  [session db-name & rest]
  (let [{:keys [db-parameters] :as options} rest
        db-parameters-str (string/join " " (map name db-parameters))]
    ;; Postgres simply has no way to check if a database exists and issue a
    ;; "CREATE DATABASE" only in the case that it doesn't. That would require a
    ;; function, but create database can't be done within a transaction, so
    ;; you're screwed. Instead, we just use the fact that trying to create an
    ;; existing database does nothing and stuff the output/error return.
    (apply postgresql-script
           session
           :content (format "CREATE DATABASE %s %s;" db-name db-parameters-str)
           :literal true
           (conj (vec rest) :ignore-result true))))

;; This is a format string that generates a temporary PL/pgsql function to
;; check if a given role exists, and if not create it. The first argument
;; should be the role name, the second should be any user-parameters.
(def ^{:private true} create-role-pgsql
"create or replace function pg_temp.createuser() returns void as $$
 declare user_rec record;
 begin
 select into user_rec * from pg_user where usename='%1$s';
 if user_rec.usename is null then
     create role %1$s %2$s;
 end if;
 end;
 $$ language plpgsql;
 select pg_temp.createuser();")

(defn create-role
  "Create a postgres role if it does not exist.

   You can specify user parameters by including a keyed parameter called
   :user-parameters, which indicates a vector of strings or keywords that will
   get translated in order to the options to the create user command. Passes on
   key/value arguments to postgresql-script.

   Example (create-role
             \"myuser\" :user-parameters [:encrypted :password \"'mypasswd'\"])"
  [session username & rest]
  (let [{:keys [user-parameters] :as options} rest
        user-parameters-str (string/join " " (map name user-parameters))]
    (apply postgresql-script
           session
           :content (format create-role-pgsql username user-parameters-str)
           :literal true
           rest)))


(defn service
  "Control the postgresql service.

   Specify `:if-config-changed true` to make actions conditional on a change in
   configuration.

   Other options are as for `pallet.action.service/service`. The service
   name is looked up in the request parameters."
  [session & {:keys [action if-config-changed if-flag] :as options}]
  (let [service (parameter/get-for-target
                 session [:postgresql :service])
        options (if if-config-changed
                  (assoc options :if-flag postgresql-config-changed-flag)
                  options)]
    (-> session (thread-expr/apply-map-> service/service service options))))




© 2015 - 2025 Weber Informatics LLC | Privacy Policy