require 'yaml' module Prick # There is only one State object: Prick.state # # FIXME Not how it is done # The prick.state file contains the current database, username, and # environment. It is controlled by prick(1) but you can set its values by # using 'prick database=asdf # class State # Prick project dir. This is not a constant because prick can change # directory through the -C option or the 'init' command def prick_dir() @prick_dir ||= Dir.getwd end # Prick schema dir def schema_dir() @schema_dir ||= File.join(prick_dir, SCHEMA_DIR) end # Project file. Default 'prick.yml' attr_reader :project_file # Environment file. Default 'prick.environment'. Note that the file can be # absent if the project doesn't use environments attr_reader :environment_file # Reflections file. Default 'schema/reflections.yml'. May be nil if the # file is absent attr_reader :reflections_file # State file. Default '.prick-state.yml' attr_reader :state_file # Fox state file. Default '.fox-state.yml' attr_reader :fox_state_file # Schema data file. FIXME What is this? def schema_file() SCHEMA_VERSION_PATH end # True if the configuration files has been loaded def project_loaded? = @project_loaded def state_loaded? = @state_loaded def environment_loaded? = @environment_loaded # Used as an identifier and the default database and username attr_accessor :name # Capitalized name of project attr_accessor :title # Project version in prick.yml. Can be nil FIXME Can it? attr_accessor :version # Version of prick in prick.yml. Note that this can be different than the current # version of prick attr_accessor :prick_version # Database name. nil if state file is absent attr_accessor :database # Database owner name. Typically the same as the database name. nil if # database is absent attr_accessor :username # Map from environment name to environment object attr_reader :environments # Name of current environment. If not set in the state file, the enviroment # is read from the database when the first connection is established by the # #connection method. Use '#environments[environment]' to get the # corresponding Environment object def environment() @environment end def environment=(env) constrain env, String, nil env.nil? || environments.key?(env) or raise "Illegal environment: '#{env}'" @environment = env end # Project version from PRICK.VERSIONS. Initialized by #connection attr_accessor :database_version # Prick version from PRICK.VERSIONS. Initialized by #connection attr_accessor :database_prick_version # Environment from PRICK.VERSIONS. Initialized by #connection attr_accessor :database_environment # Git branch. Lazy-evaluated def branch() @branch ||= Git.branch.current end # Git revision (commit ID). Lazy-evaluated def rev(kind: :long) case kind when :short; @rev_short ||= rev()[0...8] when :long; @rev_long ||= Git.id end end # True if the git repository is clean (not modified). Lazy-evaluated def clean?() return @clean if defined?(@clean) @clean = Git.clean? end def initialize(project_file, environment_file, reflections_file, state_file, fox_state_file) @project_file, @environment_file, @reflections_file, @state_file, @fox_state_file = project_file, environment_file, reflections_file, state_file, fox_state_file @project_loaded = @state_loaded = @environment_loaded = false if @project_file && File.exist?(@project_file) load_project_file load_state_file if @state_file && File.exist?(@state_file) end # FIXME The environment file should be loaded on-demand but it is hard to # do when the environments are accessed through a class-interface load_environment_file if @environment_file && File.exist?(@environment_file) end # Project user (owner) connection. Memoized to connect only once. # TODO Rename. Also rename self.connection def connection(database: nil, username: nil, environment: nil, &block) if @connection.nil? database ||= self.database username ||= self.username environment ||= self.environment !database.nil? or Prick.error "Can't connect to Postgres - no database specified" # exist_database_environment? or Prick.error "Database '#{database}' is not initialized" @connection = PgConn.new(database, username) # Set database_version/environment/prick members load_database_environment # Set environment if undefined and not overridden by :environment self.environment = environment || Prick.state.environment || environments.key?(database_environment) && database_environment || DEFAULT_ENVIRONMENT end if block_given? yield @connection else @connection end end # Superuser connection. This is a connection to Postgres using the current # user's credentials. It is assumed that the current user has a postgres # superuser account with the same name as the user's. Memoized to connect # only once def self.connection(&block) @@connection ||= PgConn.new("postgres") if block_given? yield @@connection else @@connection end end # Short-hand for #connection alias_method :conn, :connection # Prick executable search_path. This includes the bin and libexec directories def executable_search_path @executable_search_path ||= "#{ENV['PATH']}:#{prick_dir}/#{BIN_DIR}:#{prick_dir}/#{LIBEXEC_DIR}" end # Create a bash(1) environment (Hash). It is used for in-prick expansion of # variables and is also injected into the enviroment of subprocesses # # FIXME: Problems with BUNDLE_* variables FIXME Still a problem? # # TODO: Explain handling of PRICK_ def bash_environment(all: true) @bash_environment ||= begin hash = { "PATH" => Prick.state.executable_search_path } if all hash.merge!({ "PRICK_DIR" => Prick.state.prick_dir, "PRICK_SCHEMADIR" => File.join(Prick.state.prick_dir, SCHEMA_DIR), "PRICK_BINDIR" => File.join(Prick.state.prick_dir, BIN_DIR), "PRICK_LIBEXECDIR" => File.join(Prick.state.prick_dir, LIBEXEC_DIR), "PRICK_VARDIR" => File.join(Prick.state.prick_dir, VAR_DIR), "PRICK_CACHEDIR" => File.join(Prick.state.prick_dir, CACHE_DIR), "PRICK_SPOOLDIR" => File.join(Prick.state.prick_dir, SPOOL_DIR), "PRICK_TMPDIR" => File.join(Prick.state.prick_dir, TMP_DIR), "PRICK_CLONEDIR" => File.join(Prick.state.prick_dir, CLONE_DIR), "PRICK_SPECDIR" => File.join(Prick.state.prick_dir, BIN_DIR), }) end hash.merge!({ "DATABASE" => Prick.state.database, # FIXME: Yt "USERNAME" => Prick.state.username, # FIXME: Yt "ENVIRONMENT" => Prick.state.environment.to_s, # FIXME: Yt except in build.yml parser "PRICK_NAME" => Prick.state.name, "PRICK_TITLE" => Prick.state.title, "PRICK_VERSION" => Prick.state.version, "PRICK_DATABASE" => Prick.state.database, "PRICK_USERNAME" => Prick.state.username, "PRICK_ENVIRONMENT" => Prick.state.environment&.to_s, # may be the empty string }) # PRICK_ENVIRONMENT_* variables. Only defined if the environment is known if !Prick.state.environment.nil? && environments.key?(environment) hash.merge! environments[environment].bash_environment end end end # @scope can be :global (variables are exported), :local (variables are # declared local), or nil (variables are global but not exported) # # Only non-text variables are emitted def bash_source(vars = nil, scope: nil) exclude = Array(exclude || []).flatten.map { _1.to_s } case scope when :global; prefix="export " when :local; prefix="local " when nil; prefix="" else raise ArgumentError, "Illegal value for scope: #{scope.inspect}" end vars ||= bash_environment&.keys || [] assignments = [] vars.each { |var| val = bash_environment[var] # next if val =~ /['"\n]/m # We don't quote if val.is_a?(Array) if val.first.is_a?(Array) val = val.map { |v| v.join("\n") }.join("\n") else val = val.join(" ") end end assignments << "#{prefix}#{var}='#{val}'\n" } assignments.join end # It is an error if the project file exists. def save_project(overwrite: false) overwrite || !File.exists?(project_file) or Prick.error "Won't overwrite '#{project_file}'" hash = { name: name, title: title, version: version.to_s, prick: Prick::VERSION } save_yaml(project_file, hash) end # FIXME: Ugly def save_version system("sed -i 's/^version:.*/version: #{version.to_s} #{project_file}/'") end # Used by 'prick setup' def save_state(database = nil, username = nil, environment = nil) database ||= self.database or raise ArgumentError username ||= self.username or raise ArgumentError environment ||= self.environment save_yaml(state_file, database: database, username: username, environment: environment) end # Save build-start information to PRICK.BUILDS. It is a nop if PRICK.BUILDS # doesn't exist, this happens on first build in a completely empty database def save_build_begin @build_id = nil # Used by save_build_end if conn.schema.exist_table?("prick", "builds") @build_id = insert_record( "prick.builds", name: name, version: version.to_s, prick: Prick::VERSION, branch: branch, rev: rev(kind: :short), clean: clean?, environment: environment) end # version: version.to_s, prick: prick_version, end def save_build_end(success, duration) dt = Time.now - TIME if @build_id update_record("prick.builds", @build_id, success: success, duration: duration, prick_duration: dt) else insert_record( "prick.builds", name: name, version: version.to_s, prick: Prick::VERSION, branch: branch, rev: rev(kind: :short), clean: clean?, environment: environment, success: success, duration: duration, prick_duration: dt) end end def save_build(success = true) insert_record( "prick.builds", name: name, version: version.to_s, prick: Prick::VERSION, branch: branch, rev: rev(kind: :short), clean: clean?, environment: environment, success: success) end def dump puts "State" indent { for method in [ :name, :title, :prick_version, :project_version, :database_version, :database_environment, :database, :username] puts "#{method}: #{self.send method}" end puts "environments:" indent { environments.dump } } end private # YAML helper methods def read_yaml(file) begin YAML.load File.read(file).sub(/^__END__\n.*/m, "") rescue Errno::ENOENT Prick.error "Can't read #{file}" end end def load_yaml(file, mandatory_keys, optional_keys = []) mandatory_keys = mandatory_keys.map(&:to_s) optional_keys = optional_keys.map(&:to_s) hash = read_yaml(file) or Prick.error "Not a valid YAML file - #{file}" for key in mandatory_keys !hash[key].to_s.empty? or Prick.error "Can't find '#{key}' in #{file}" end (unknown = (hash.keys - mandatory_keys - optional_keys).first) and Prick.error "Illegal key '#{unknown}' in #{file}" hash end def save_yaml(file, hash) self.class.save_yaml(file, hash) end def self.save_yaml(file, hash) IO.write(file, hash.map { |k,v| [k.to_s, v] }.to_h.to_yaml) end # Database helper methods. TODO: Make generally available # FIXME FIXME FIXME Yt. Replace with PgConn methods def select_record(table, id, columns = []) sql_columns = columns.join(", ") sql_id_cond = (id.nil? ? "true" : "id = #{id}") connection.tuple "select #{sql_columns} from #{table} where #{sql_id_cond}" end def select_record?(table, id, columns = []) sql_columns = columns.join(", ") sql_id_cond = (id.nil? ? "true" : "id = #{id}") connection.tuple? "select #{sql_columns} from #{table} where #{sql_id_cond}" end def insert_record(table, attrs = {}) sql_fields = attrs.keys.join(", ") sql_values = attrs.values.map { conn.quote_value(_1) }.join(", ") conn.value "insert into #{table} (#{sql_fields}) values (#{sql_values}) returning id" end def update_record(table, id, attrs = {}) sql_assign = attrs.map { |k,v| "#{conn.quote_identifier(k)} = #{conn.quote_value(v)}" }.join(", ") sql_id_cond = (id.nil? ? "true" : "id = #{id}") conn.exec "update #{table} set #{sql_assign} where #{sql_id_cond}" end # State helper methods def load_project_file hash = load_yaml(project_file, %w(name title), %w(version prick)) @name = hash["name"] @title = hash["title"] @version = hash["version"] && PrickVersion.new(hash["version"]) @prick_version = hash["prick"] && PrickVersion.new(hash["prick"]) @project_loaded = true end def load_environment_file hash = environment_file ? read_yaml(environment_file) : {} @environments = Environments.new(hash) @environment_loaded = true end def load_state_file hash = load_yaml(state_file, %w(database username), %w(environment)) @database = hash["database"] @username = hash["username"] @environment = hash["environment"] @state_loaded = true end # Database helper methods # Return true if the database environment exists def exist_database_environment? connection.schema.exist_relation?("prick", "versions") end # Loads database environment. It is an error if the prick schema is absent def load_database_environment version, environment, prick_version = connection.tuple %( select version, environment, prick from prick.versions ) @database_version = PrickVersion.new(version) @database_environment = environment @database_prick_version = PrickVersion.new(prick_version) end end end