# encoding: UTF-8 # frozen_string_literal: true # Requirements # ======================================================================= # Stdlib # ----------------------------------------------------------------------- # Deps # ----------------------------------------------------------------------- require 'cmds' # Project / Package # ----------------------------------------------------------------------- # Refinements # ======================================================================= require 'nrser/refinements/types' using NRSER::Types # Definitions # ======================================================================= # Use `newsyslog` to rotate {Locd::Agent} log files. # module Locd::Newsyslog include NRSER::Log::Mixin # Default place to work out of. # # @return [Pathname] # DEFAULT_WORKDIR = Pathname.new( '~/.locd/tmp/newsyslog' ).expand_path # Lil' class that holds values for a `newsyslog` conf file entry. # # @see https://developer.apple.com/legacy/library/documentation/Darwin/Reference/ManPages/man5/newsyslog.conf.5.html # @see https://archive.is/nIiP9 # class Entry # Constants # ====================================================================== # Value for the `when` field when we want to never rotate based on time. # # @return [String] # WHEN_NEVER = '*' # Conf file value for `flags` where there are none # # @return [String] # NO_FLAGS = '-' FIELD_SEP = " " # Class Methods # ====================================================================== # @todo Document normalize_mode method. # # @param [type] arg_name # @todo Add name param description. # # @return [return_type] # @todo Document return value. # def self.mode_string_for mode # t.match mode, # 0000..0777, mode.to_s( 8 ), # /\A[0-7]{3}\z/, mode case mode when 0000..0777 mode.to_s 8 when /\A[0-7]{3}\z/ mode else raise ArgumentError, "Bad mode: #{ mode.inspect }, need String or Fixnum: '644'; 0644" end end # .normalize_mode def self.sig_num_for signal case signal when String Signal.list.fetch signal when Fixnum signal else raise ArgumentError, "Bad signal: #{ signal.inspect }, need String or Fixnum: 'HUP'; 1" end end # Attributes # ====================================================================== attr_reader :log_path, :pid_path, :owner, :group, :mode, :count, :size, :when_, :flags, :sig_num # Constructor # ====================================================================== # Instantiate a new `Entry`. def initialize log_path:, pid_path:, owner: nil, group: nil, mode: '644', count: 7, size: 100, # 100 KB max size when_: '*', # '$D0', # rotate every day at midnight flags: [], signal: 'HUP' @log_path = log_path @pid_path = pid_path @owner = owner @group = group @mode = self.class.mode_string_for mode @count = t.pos_int.check count @size = t.pos_int.check size @when_ = when_ @flags = flags @sig_num = self.class.sig_num_for signal render end # #initialize # Instance Methods # ====================================================================== def sig_name Signal.signame @sig_num end def render_flags if flags.empty? NO_FLAGS else flags.join end end def render @render ||= begin fields = [ log_path, "#{ owner }:#{ group }", mode, count, size, when_, render_flags, ] if pid_path fields << pid_path fields << sig_num end fields.map( &:to_s ).join FIELD_SEP end end end # class Entry # Module Methods # ============================================================================ # Run `newsyslog` for an agent to rotate it's log files (if present and # needed). # # @param [Locd::Agent] agent: # Agent to run for. # # @param [Pathname | String] workdir: # Directory to write working files to, which are removed after successful # runs. # # @return [Cmds::Result] # Result of running the `newsyslog` command. # # @return [nil] # If we didn't run the command 'cause the agent doesn't have any logs # (that we know/care about). # def self.run agent, workdir: DEFAULT_WORKDIR logger.debug "Calling {.run_for} agent #{ agent.label }...", agent: agent, workdir: workdir # Make sure `workdir` is a {Pathname} workdir = workdir.to_pn # Collect the unique log paths log_paths = agent.log_paths if log_paths.empty? logger.info "Agent #{ agent.label } has no log files." return nil end logger.info "Setting up to run `newsyslog` for agent `#{ agent.label }`", log_paths: log_paths.map( &:to_s ) # NOTE Total race condition since agent may be started after this and # before we rotate... f-it for now. pid_path = nil if pid = agent.pid( refresh: true ) logger.debug "Agent is running", pid: pid pid_path = workdir / 'pids' / "#{ agent.label }.pid" FileUtils.mkdir_p( pid_path.dirname ) unless pid_path.dirname.exist? pid_path.write pid logger.debug "Wrote PID #{ pid } to file", pid_path: pid_path end entries = log_paths.map { |log_path| Entry.new log_path: log_path, pid_path: pid_path } conf_contents = entries.map( &:render ).join( "\n" ) + "\n" logger.debug "Generated conf entries", entries.map { |entry| [ entry.log_path.to_s, entry.instance_variables.map_values { |name| entry.instance_variable_get name } ] }.to_h conf_path = workdir / 'confs' / "#{ agent.label }.conf" FileUtils.mkdir_p( conf_path.dirname ) unless conf_path.dirname.exist? conf_path.write conf_contents logger.debug "Wrote entries to conf file", conf_path: conf_path.to_s, conf_contents: conf_contents cmd = Cmds.new "newsyslog <%= opts %>", kwds: { opts: { # Turn on verbose output v: true, # Point to the conf file f: conf_path, # Don't run as root r: true, } } logger.info "Executing `#{ cmd.prepare }`" result = cmd.capture if result.ok? logger.info \ "`newsyslog` command succeeded for agent `#{ agent.label }`" + ( result.out.empty? ? nil : ", output:\n" + result.out.indent(1, indent_string: '> ') ) FileUtils.rm( pid_path ) if pid_path FileUtils.rm conf_path logger.debug "Files cleaned up." else logger.error "`newsyslog` command failed for agent #{ agent.label }", result: result.to_h end logger.debug "Returning", result: result.to_h result end # .run # Call {.run} for each agent. # # @param workdir (see .run) # # @return [Hash] # Hash mapping each agent to it's {.run} result (which may be `nil`). # def self.run_all workdir: DEFAULT_WORKDIR, trim_logs: true log_to_file do Locd::Agent.all.values. reject { |agent| agent.label == Locd::ROTATE_LOGS_LABEL }. map { |agent| [agent, run( agent, workdir: workdir )] }. to_h. tap { |_| self.trim_logs if trim_logs } end end # .run_all def self.log_dir @log_dir ||= Locd.config.log_dir / Locd::ROTATE_LOGS_LABEL end def self.log_to_file &block time = Time.now.iso8601 date = time.split( 'T', 2 )[0] # Like # # ~/.locd/log/com.nrser.locd.rotate-logs/2018-02-14/2018-02-14T03:46:57+08:00.log # path = self.log_dir / date / "#{ time }.log" FileUtils.mkdir_p( path.dirname ) unless path.dirname.exist? appender = SemanticLogger.add_appender \ file_name: path.to_s begin result = block.call ensure SemanticLogger.remove_appender appender end end def self.trim_logs keep_days: 7 logger.info "Removing old self run log directories...", log_dir: self.log_dir.to_s, keep_days: keep_days unless self.log_dir.directory? logger.warn "{Locd::Newsyslog.log_dir} does not exist!", log_dir: self.log_dir return nil end day_dirs = self.log_dir.entries.select { |dir_name| dir_name.to_s =~ /\d{4}\-\d{2}\-\d{2}/ && (self.log_dir / dir_name).directory? } to_remove = day_dirs.sort[0...(-1 * keep_days)] if to_remove.empty? logger.info "No old self run log directories to remove." else to_remove.each { |dir_name| path = self.log_dir / dir_name logger.info "Removing old day directory", path: path FileUtils.rm_rf path } logger.info "Done.", log_dir: self.log_dir.to_s, keep_days: keep_days end to_remove end end # module Locd::Newsyslog