Cleanup old DONE VMs folders and log files

Hello.

We recently reach an inode use limit for /var/lib/one on our frontend and I found that there is no builtin tool to cleanup old VM directories (/var/lib/one/vms/[0-9]*) and log files (/var/lib/one/[0-9]*.log).

So, to celebrate our 6.2.0-1.ce migration, here is the little tool I wrote to cleanup them, called monthly in oneadmin crontab.

EDIT: It was not the final version with working --no-directory, --no-log-file and --no-db options.

#!/usr/bin/ruby
# frozen_string_literal: true

############################################################################
# Copyright 2022, Équipe EOLE <eole@ac-dijon.fr>                           #
# Author: Daniel Dehennin <daniel.dehennin@ac-dijon.fr>                    #
#                                                                          #
# This program is free software: you can redistribute it and/or modify     #
# it under the terms of the GNU Affero General Public License as           #
# published by the Free Software Foundation, either version 3 of the       #
# License, or (at your option) any later version.                          #
#                                                                          #
# This program is distributed in the hope that it will be useful,          #
# but WITHOUT ANY WARRANTY; without even the implied warranty of           #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the            #
# GNU Affero General Public License for more details.                      #
#                                                                          #
# You should have received a copy of the GNU Affero General Public License #
# along with this program.  If not, see <http://www.gnu.org/licenses/>.    #
############################################################################

############################################################################
ONE_LOCATION = ENV['ONE_LOCATION']

if !ONE_LOCATION
  RUBY_LIB_LOCATION = '/usr/lib/one/ruby'
  GEMS_LOCATION     = '/usr/share/one/gems'
else
  RUBY_LIB_LOCATION = ONE_LOCATION + '/lib/ruby'
  GEMS_LOCATION     = ONE_LOCATION + '/share/gems'
end

# %%RUBYGEMS_SETUP_BEGIN%%
if File.directory?(GEMS_LOCATION)
  real_gems_path = File.realpath(GEMS_LOCATION)
  if !defined?(Gem) || Gem.path != [real_gems_path]
    $LOAD_PATH.reject! { |l| l =~ /vendor_ruby/ }

    # Suppress warnings from Rubygems
    # https://github.com/OpenNebula/one/issues/5379
    begin
      verb = $VERBOSE
      $VERBOSE = nil
      require 'rubygems'
      Gem.use_paths(real_gems_path)
    ensure
      $VERBOSE = verb
    end
  end
end
# %%RUBYGEMS_SETUP_END%%

$LOAD_PATH << RUBY_LIB_LOCATION
$LOAD_PATH << RUBY_LIB_LOCATION + '/onedb'
$LOAD_PATH << RUBY_LIB_LOCATION + '/cli'

require 'fileutils'
require 'optparse'
require 'optparse/time'

require 'cli/one_helper' # Required for OneDBLive.purge_done_vm
require 'opennebula'
require 'onedb_live'

VMS_DIR = '/var/lib/one/vms'
LOG_DIR = '/var/log/one'

DONE_STATE = OpenNebula::VirtualMachine::VM_STATE.index('DONE')
NOW = Time.now
# Older than 30 days ago at midnight
DEFAULT_TIME_LIMIT = NOW - 30 * 86_400 - NOW.hour * 3600 - NOW.min * 60 - NOW.sec - NOW.subsec

def main
  options = parse_opts

  begin
    client = OpenNebula::Client.new
  rescue StandardError => e
    puts "OpenNebula error: #{e}"
    exit(-1)
  end

  vm_pool = OpenNebula::VirtualMachinePool.new(client, OpenNebula::Pool::INFO_ALL)

  clean_done_vm_files(vm_pool, options)
  clean_unknow_vm_files(vm_pool, options)
  purge_old_done_vms(options)
end

def clean_done_vm_files(vm_pool, options)
  # Lookup all DONE VMs
  rc = vm_pool.info_search(state: DONE_STATE)
  raise "OpenNebula error: #{rc.message}" if OpenNebula.is_error?(rc)

  vm_pool.each do |vm|
    vm_dir = "#{VMS_DIR}/#{vm.id}"
    log_file = "#{LOG_DIR}/#{vm.id}.log"
    etime = Time.at(vm.retrieve_elements('ETIME').first.to_i)

    if etime > options[:time]
      puts "#{vm.id}/#{vm.name}: skip: #{parse_remaining_time(etime - options[:time])} remaining" if options[:debug]
      next
    end

    clean_vm_dir(vm_dir, vm, options)
    clean_vm_logfile(log_file, vm, options)
  end
end

def clean_unknow_vm_files(vm_pool, options)
  vms_glob = "#{VMS_DIR}/[0-9]*"
  log_glob = "#{LOG_DIR}/[0-9]*.log"

  Dir[vms_glob].each do |vm_dir|
    next unless File.directory?(vm_dir)

    vm_id = File.basename(vm_dir).to_i
    vm = lookup_vm(vm_id, vm_pool)
    if vm.nil?
      clean_vm_dir(vm_dir, vm, options)
    elsif options[:debug]
      puts "#{vm.id}/#{vm.name}: keep #{vm.state_str} VM directory #{vm_dir}"
    end
  end

  Dir[log_glob].each do |log_file|
    next unless File.file?(log_file)

    vm_id = File.basename(log_file, '.log').to_i
    vm = lookup_vm(vm_id, vm_pool)
    if vm.nil?
      clean_vm_logfile(log_file, vm, options)
    elsif options[:debug]
      puts "#{vm.id}/#{vm.name}: keep #{vm.state_str} VM log file #{log_file}"
    end
  end
end

def purge_old_done_vms(options)
  return unless options[:db]

  if options[:dryrun]
    puts "Would purge VM in DONE state older than '#{options[:time]}' from OpenNebula database"
    return
  end
  puts "Purge VM in DONE state older than '#{options[:time]}' from OpenNebula database" if options[:verbose]

  purge_options = {
    end_time: options[:time],
    verbose: options[:verbose]
  }
  action = OneDBLive.new
  rc     = action.purge_done_vm(purge_options)

  raise "OpenNebula error: #{rc.message}" if OpenNebula.is_error?(rc)
end

def lookup_vm(vm_id, vm_pool)
  rc = vm_pool.info_set(vm_id.to_s, false)
  raise "OpenNebula error: #{rc.message}" if OpenNebula.is_error?(rc)

  vm_pool.first
end

def clean_vm_dir(vm_dir, vm, options)
  return unless options[:directory]

  log_prefix = if vm.nil?
                 'Unknown VM: '
               else
                 "#{vm.id}/#{vm.name}: "
               end

  if File.writable?(vm_dir)
    puts "#{log_prefix}#{options[:action_str]} directory #{vm_dir}" if options[:verbose] || options[:dryrun]
    FileUtils.rm_rf(vm_dir) unless options[:dryrun]
  else
    puts "#{log_prefix}directory #{vm_dir} does not exists" if options[:debug]
  end
end

def clean_vm_logfile(log_file, vm, options)
  return unless options[:logfile]

  log_prefix = if vm.nil?
                 'Unknown VM: '
               else
                 "#{vm.id}/#{vm.name}: "
               end

  if File.writable?(log_file)
    puts "#{log_prefix}#{options[:action_str]} log file #{log_file}" if options[:verbose] || options[:dryrun]
    FileUtils.rm_f(log_file) unless options[:dryrun]
  else
    puts "#{log_prefix}log file #{log_file} does not exists" if options[:debug]
  end
end

def parse_remaining_time(seconds)
  labels = %w[day hour minute second]
  reduced = [60, 60, 24].reduce([seconds]) do |m, o|
    m.unshift(m.shift.divmod(o)).flatten
  end

  remaining_string = ''
  reduced.zip(labels).each do |pair|
    next if pair[0].zero?

    plural = 's' if pair[0] > 1
    comma = ', ' unless remaining_string.empty?
    remaining_string += if pair[1] == 'seconds'
                          " and #{pair[0]} #{pair[1]}#{plural}"
                        else
                          "#{comma}#{pair[0]} #{pair[1]}#{plural}"
                        end
  end
  remaining_string
end

def parse_opts
  options = {
    time: DEFAULT_TIME_LIMIT,
    directory: true,
    logfile: true,
    db: true,
    dryrun: true,
    verbose: false,
    debug: false
  }

  OptionParser.new do |parser|
    parser.banner = "Usage: #{File.basename(__FILE__)}"

    parser.on(
      '-t',
      '--time TIME',
      Time,
      "Cleanup VMs older than this date, default to '#{DEFAULT_TIME_LIMIT}'"
    ) do |value|
      options[:time] = value
    end

    parser.on(
      '--no-directory',
      "Do not cleanup the VMs directories under '#{VMS_DIR}'"
    ) do |value|
      options[:directory] = value
    end

    parser.on(
      '--no-log-file',
      "Do not cleanup the VMs log file under '#{LOG_DIR}'"
    ) do |value|
      options[:logfile] = value
    end

    parser.on(
      '--no-db',
      'Do not cleanup the VMs in DONE state from OpenNebula database'
    ) do |value|
      options[:db] = value
    end

    parser.on(
      '-v',
      '--verbose',
      'Show verbose informations'
    ) do |value|
      options[:verbose] = value
    end

    parser.on(
      '-d',
      '--debug',
      'Show debug informations'
    ) do |value|
      options[:debug] = value
    end

    parser.on(
      '--no-dry-run',
      'Do not run in dry-run mode'
    ) do |value|
      options[:dryrun] = value
    end
  end.parse!

  # Debug enable verbose
  options[:verbose] = options[:debug] || options[:verbose]

  options[:action_str] = if options[:dryrun]
                           'Would remove'
                         else
                           'Remove'
                         end

  options
end

main

Lets publish our sysadmin tools online.

Regards.

2 Likes