diff --git a/app/controllers/clients_controller.rb b/app/controllers/clients_controller.rb index 2111e79..70f5f8b 100644 --- a/app/controllers/clients_controller.rb +++ b/app/controllers/clients_controller.rb @@ -1,174 +1,188 @@ class ClientsController < ApplicationController before_action :require_logged_in before_action :fetch_client, only: [:show, :jobs, :logs, :stats, :users, :restore, :run_restore, :restore_selected, :remove_user] before_action :fetch_logs, only: [:logs] before_action :require_non_blocked_client, only: [:restore, :restore_selected, :run_restore] # GET /clients # POST /clients def index @client_ids = Client.for_user(current_user.id).pluck(:ClientId) @clients = Client.where(ClientId: @client_ids).includes(:jobs) @hosts = current_user.hosts.not_baculized fetch_jobs_info get_charts end # GET /clients/1 def show @schedules = @client.host.job_templates.map(&:schedule) @filesets = @client.host.job_templates.map(&:fileset) end # GET /clients/1/jobs def jobs @jobs = @client.recent_jobs.page(params[:page]) end # GET /clients/1/logs def logs; end # GET /clients/1/stats # POST /clients/1/stats def stats get_charts end # GET /clients/1/users def users @users = @client.host.users if @client.manually_inserted? @invitation = @client.host.invitations.new excluded_ids = @users.pluck(:id) + @client.host.invitations.pluck(:user_id) @available_users = User.where(enabled: true).institutional. where.not(id: excluded_ids).pluck(:username, :id) end end # DELETE /clients/1/user def remove_user user = @client.host.users.find(params[:user_id]) redirect_path = users_client_path(@client) if @client.host.users.count == 1 flash[:alert] = 'You can not remove the last user' elsif @client.host.users.delete(user) flash[:success] = if @client.manually_inserted? 'User successfully removed' else 'User must be removed from the VM\'s list from your VM provider too (ViMa or Okeanos).' end if user.id == current_user.id redirect_path = clients_path end else flash[:alert] = 'User not removed, something went wrong' end redirect_to redirect_path end # GET /clients/1/restore def restore + @restore_clients = Client.for_user(current_user.id) + return if @client.is_backed_up? flash[:error] = 'Can not issue a restore for this client' redirect_to client_path(@client) end # POST /clients/1/run_restore def run_restore @location = params[:restore_location].blank? ? '/tmp/bacula_restore' : params[:restore_location] fileset = params[:fileset] restore_point = fetch_restore_point + restore_client = fetch_restore_client if params[:commit] == 'Restore All Files' - if @location.nil? || fileset.nil? || !@client.host.restore(fileset, @location, restore_point) + if @location.nil? || fileset.nil? || + !@client.host.restore(fileset, @location, restore_point, restore_client) flash[:error] = 'Something went wrong, try again later' else - flash[:success] = - "Restore job issued successfully, files will be soon available in #{@location}" + msg = "Restore job issued successfully, files will be soon available in #{@location}" + msg << " of client #{restore_client}" if restore_client.present? + flash[:success] = msg end render js: "window.location = '#{client_path(@client)}'" else session[:job_ids] = @client.get_job_ids(fileset, restore_point) + session[:restore_client] = restore_client Bvfs.new(@client, session[:job_ids]).update_cache render 'select_files' end end # POST /clients/1/restore_selected def restore_selected - Bvfs.new(@client, session[:job_ids]).restore_selected_files(params[:files], params[:location]) + Bvfs.new(@client, session[:job_ids]). + restore_selected_files(params[:files], params[:location], nil, session[:restore_client]) session.delete(:job_ids) + session.delete(:restore_client) flash[:success] = "Restore job issued successfully, files will be soon available in #{params[:location]}" redirect_to client_path(@client) end # GET /clients/1/tree?id=1 def tree @client = Client.for_user(current_user.id).find(params[:client_id]) bvfs = Bvfs.new(@client, session[:job_ids]) pathid = params[:id].to_i if pathid.nonzero? bvfs.fetch_dirs(pathid) else bvfs.fetch_dirs end tree = bvfs.extract_dir_id_and_name.map do |id, name| { id: id, text: name, state: { checkbox_disabled: true }, children: true } end if pathid.nonzero? bvfs.fetch_files(pathid) bvfs.extract_file_id_and_name.each do |id, name| tree << { id: id, text: name, type: 'file' } end end render json: tree end private def require_non_blocked_client if @client.host.blocked? flash[:error] = 'Client disabled by admins' redirect_to clients_path end end def fetch_client @client = Client.for_user(current_user.id).find(params[:id]) @client_ids = [@client.id] end + def fetch_restore_client + if params[:restore_client] + Client.for_user(current_user.id).find_by(ClientId: params[:restore_client]).try(:name) + end + end + def fetch_jobs_info @stats = JobStats.new(@client_ids) end def get_charts @job_status = ChartGenerator.job_statuses(@client_ids, days_ago) @job_stats = ChartGenerator.job_stats(@client_ids, days_ago - 1) end def fetch_restore_point if params['restore_time(4i)'].blank? || params['restore_time(5i)'].blank? || params[:restore_date].blank? return nil end restore_point = "#{params[:restore_date]} #{params['restore_time(4i)']}:#{params['restore_time(5i)']}:00" begin DateTime.strptime(restore_point, '%Y-%m-%d %H:%M:%S') rescue return nil end restore_point end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 467f9b8..e9f8477 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,174 +1,192 @@ module ApplicationHelper # Custom helper for better display of big numbers # @example number_by_magnitude(4242) # "4.2K" # # @param number[Numeric] # @return [String] human friendly respresentation def number_by_magnitude(number) number_to_human(number, units: { thousand: :K, million: :M, billion: :G }) end # Creates a bootstrap form-group div with an additional 'Add' button next to the select field # # @param object[ActiveRecord::Object] the form's subject # @param resource[Symbol] the objects class # @param attr[Symbol] the select box's attribute # @param attr_name[String] the attribute's display name # @param options[Array] the select box options # @param path[String] the add button's path def select_with_errors_and_button(object, resource, attr, attr_name, options, path) has_errors = object.errors[attr].present? content_tag(:div, class: "form-group #{' has-error' if has_errors }") do attr_label = label(resource, attr, attr_name, class: 'control-label col-xs-5 required') select_div = content_tag(:div, class: 'col-xs-5') do select_part = select_tag([resource, attr].join('_').to_sym, options, include_blank: true, name: "#{resource}[#{attr}]", class: 'form-control' ) if has_errors select_part.concat(content_tag(:span, class: 'help-block') { object.errors[attr].first }) end select_part end button_part = content_tag(:div, class: 'col-xs-1') do link_to path do content_tag(:span, class: 'glyphicon glyphicon-plus text-success') {} end end attr_label.concat(select_div).concat(button_part) end end # Returns a style class depending on the given parameter # # @param status[Char] def success_class(status) case status when 'T' then 'success' when 'E' then 'danger' when 'f' then 'fatal' end end # Fetches the html class for a given path # # @param path[String] the path to check for # @param partial[Boolean] forces a left partial match # # @return [Hash] { class: 'active' } if the given path is the current page def active_class(path, partial = false) if current_page?(path) || (partial && request.path.starts_with?(path)) { class: 'active' } else {} end end # Constructs a breadcrumb out the given options # # @param options[Hash] a hash containing the breadcrumb links in name: path sets # @return an html ol breadcrumb def breadcrumb_with(options) content_tag(:ol, class: 'breadcrumb') do options.map { |name, path| content_tag(:li, active_class(path)) do link_to name, path end }.inject { |result, element| result.concat(element) } end end # Constructs a list with the given array elements # # @example: # inline_list([:foo, :bar]) # # # # @param arr[Array] # @return an html ul list def inline_list(arr) content_tag(:ul, class: 'list-inline') do arr.map { |element| content_tag(:li) do content_tag(:span, class: 'label label-default') do element end end }.inject { |result, element| result.concat(element) } end end # Generates a span with a yes or no and the corresponding formatting # according to the value's falseness # # @param value[Integer] def yes_no(value) klass = value == 1 ? 'label label-success' : 'label label-danger' text = value == 1 ? 'yes' : 'no' content_tag(:span, class: klass) { text } end # Generates a percentage and adds some color coding for better visibility # # @param ratio [Numeric] the ratio # @param quota [Integer] the client's space quota # # @return [String] an html label tag def pretty_percentage(ratio, quota) color = ([[ratio, 0.2].max, 0.99].min * 256).to_i.to_s(16) << '0000' content_tag(:label, class: 'label', style: "background-color:##{color}") do number_to_percentage(100 * ratio, precision: 1) end end # Generates a button that may be disabled # # @param disabled[Boolean] # @param display_text[String] # @param url[String] # @param opts[Hash] def button_or_disabled_with_label(disabled, display_text, url, opts = {}) icon_class = opts.delete(:icon_class) text_class = opts.delete(:text_class) if disabled url = '#' opts.merge!(disabled: true) opts.reverse_merge!(title: 'Client is blocked') opts.delete(:method) else opts.delete(:title) end link_to url, opts do [ content_tag(:label, class: [icon_class, text_class].join(' ')) { }, display_text ].join(' ').html_safe end end # Generates a span that contains a text and a questionmark label. # hovering on that questionmark will display a helper text # # @param text[String] the displayed text # @param tooltip[String] the displayed helper text def tooltip_label(text, tooltip) content_tag(:span, class: "data-toggle", title: tooltip) do [ text, content_tag(:label, class: "glyphicon glyphicon-question-sign") { } ].join(' ').html_safe end end + + # Generate a div that contains a helper text that is properly aligned with a form + # + # @param text[String] the displayed text + # @param label_class[String] the form's labe_col + # @param control_class[String] the form's control_col + def help_block(text, label_class, control_class) + content_tag(:div, class: 'form-group') do + [ + content_tag(:label, class: "#{label_class} control-label") { }, + content_tag(:div, class: control_class) do + content_tag(:p, class: 'form-control-static help-block') do + text + end + end + ].join(' ').html_safe + end + end end diff --git a/app/models/host.rb b/app/models/host.rb index 07897ac..b54f03b 100644 --- a/app/models/host.rb +++ b/app/models/host.rb @@ -1,389 +1,390 @@ # The bacula database must be independent from all of our application logic. # For this reason we have Host which is the application equivalent of a Bacula Client. # # A host is being created from our application. When it receives all the configuration # which is required it gets dispatched to bacula through some configuration files. After # that, a client with the exact same config is generated by bacula. class Host < ActiveRecord::Base establish_connection ARCHIVING_CONF include Configuration::Host STATUSES = { pending: 0, configured: 1, dispatched: 2, deployed: 3, updated: 4, redispatched: 5, for_removal: 6, inactive: 7, blocked: 8 } # The default file daemon port DEFAULT_PORT = 9102 enum origin: { institutional: 0, vima: 1, okeanos: 2 } serialize :email_recipients, JSON has_many :simple_configurations has_many :ownerships has_many :users, through: :ownerships, inverse_of: :hosts has_many :invitations belongs_to :client, class_name: :Client, foreign_key: :name, primary_key: :name belongs_to :verifier, class_name: :User, foreign_key: :verifier_id, primary_key: :id has_many :filesets, dependent: :destroy has_many :job_templates, dependent: :destroy has_many :schedules, dependent: :destroy validates :file_retention, :job_retention, :port, :password, presence: true validates :port, :quota, numericality: { greater_than: 0 } validates :fqdn, presence: true, uniqueness: true validate :fqdn_format validate :valid_recipients scope :not_baculized, -> { joins("left join #{Client.table_name} on #{Client.table_name}.Name = hosts.name"). where(Client.table_name => { Name: nil }) } scope :in_bacula, -> { where( status: STATUSES.select { |k,_| [:deployed, :updated, :redispatched, :for_removal].include? k }.values ) } scope :unverified, -> { where(verified: false) } before_validation :set_retention, :unset_baculized, :sanitize_name, :sanitize_email_recipients, :set_password, :set_port, :set_quota state_machine :status, initial: :pending do STATUSES.each do |status_name, value| state status_name, value: value end after_transition [:dispatched, :redispatched, :configured, :updated] => :deployed do |host| host.job_templates.enabled. update_all(baculized: true, baculized_at: Time.now, updated_at: Time.now) end event :add_configuration do transition [:pending, :dispatched, :inactive] => :configured end event :dispatch do transition :configured => :dispatched end event :redispatch do transition :updated => :redispatched end event :set_deployed do transition [:dispatched, :redispatched, :configured, :updated] => :deployed end event :change_deployed_config do transition [:deployed, :redispatched, :for_removal] => :updated end event :mark_for_removal do transition [:dispatched, :deployed, :updated, :redispatched] => :for_removal end event :set_inactive do transition [:configured, :deployed, :dispatched, :updated, :redispatched] => :inactive end event :disable do transition all => :pending end event :block do transition all - [:blocked] => :blocked end event :unblock do transition :blocked => :pending end end # API serializer # Override `as_json` method to personalize for API use. def as_json(opts={}) if for_api = opts.delete(:for_api) api_json else super(opts) end end # Determines if a host has enabled jobs in order to be dispatched to Bacula # # @return [Boolean] def bacula_ready? job_templates.enabled.any? end # Shows the host's auto_prune setting def auto_prune_human client_settings[:autoprune] end # Uploads the host's config to bacula # Reloads bacula server # # It updates the host's status accordingly def dispatch_to_bacula return false if not needs_dispatch? bacula_handler.deploy_config end # Removes a Host from bacula configuration. # Reloads bacula server # # If all go well it changes the host's status and returns true # # @param force[Boolean] forces removal def remove_from_bacula(force=false) return false if not (force || needs_revoke?) bacula_handler.undeploy_config end # Determines if a host needs a simple config # # @return [Boolean] def needs_simple_config? job_templates.none? && simple_configurations.none? end # Restores a host's backup to a preselected location # # @param fileset_id[Integer] the desired fileset # @param location[String] the desired restore location # @param restore_point[Datetime] the desired restore_point datetime - def restore(file_set_id, location, restore_point=nil) + # @param restore_client[String]] the desired restore client + def restore(file_set_id, location, restore_point=nil, restore_client=nil) return false if not restorable? job_ids = client.get_job_ids(file_set_id, restore_point) file_set_name = FileSet.find(file_set_id).file_set - bacula_handler.restore(job_ids, file_set_name, restore_point, location) + bacula_handler.restore(job_ids, file_set_name, restore_point, location, restore_client) end # Runs the given backup job ASAP def backup_now(job_name) bacula_handler.backup_now(job_name) end # Disables all jobs and sends the configuration to Bacula def disable_jobs_and_update job_templates.update_all(enabled: false) bacula_handler.deploy_config end # Disables all jobs if needed and then locks the host def disable_jobs_and_lock return false if can_set_inactive? && !disable_jobs_and_update block end # Determinex weather a host: # # * has all it takes to be deployed but # * the config is not yet sent to bacula # # @return [Boolean] def needs_dispatch? verified? && (can_dispatch? || can_redispatch?) end # Determines weather a host is marked for removal # # @return [Boolean] def needs_revoke? for_removal? end # Handles the host's job changes by updating the host's status def recalculate add_configuration || change_deployed_config end # Fetches an info message concerning the host's deploy status def display_message if !verified? { message: 'Your host needs to be verified by an admin', severity: :alert } elsif pending? { message: 'client not configured yet', severity: :alert } elsif configured? || dispatched? { message: 'client not deployed to Bacula', severity: :alert } elsif updated? || redispatched? { message: 'client configuration changed, deploy needed', severity: :alert } elsif for_removal? { message: 'pending client configuration withdraw', severity: :error } elsif inactive? { message: 'client disabled', severity: :alert } elsif blocked? { message: 'client disabled by admin.', severity: :error } end end # Determines if a host can issue a restore job. # # @return [Boolean] true if the host's client can issue a restore job def restorable? client.present? && client.is_backed_up? end # @return [User] the first of the host's users def first_user users.order('ownerships.created_at asc').first end # Marks the host as verified and sets the relevant metadata # # @param admin_verifier[Integer] the verifier's id def verify(admin_verifier) self.verified = true self.verifier_id = admin_verifier self.verified_at = Time.now recipients = users.pluck(:email) if save UserMailer.notify_for_verification(recipients, self).deliver if recipients.any? return true end false end # Determines if a host can be disabled or not. # Equivalent to is_deployed # # @return [Boolean] def can_be_disabled? dispatched? || deployed? || updated? || redispatched? end # Determines if a host is inserted manually from the user or # provided as an option from a list by the system via a third party # like ViMa or Okeanos # # @return [Boolean] def manually_inserted? institutional? end # Resets the hosts token # # @return [Boolean] def recalculate_token self.password = token save end private # automatic setters def sanitize_name self.name = fqdn end # Sets the file and job retention according to the global settings def set_retention self.file_retention = client_settings[:file_retention] self.file_retention_period_type = client_settings[:file_retention_period_type] self.job_retention = client_settings[:job_retention] self.job_retention_period_type = client_settings[:job_retention_period_type] end def unset_baculized self.baculized = false if new_record? true end def sanitize_email_recipients self.email_recipients.reject!(&:blank?) end def set_password return true if persisted? self.password = token end def token Digest::SHA256.hexdigest( Time.now.to_s + Rails.application.secrets.salt + fqdn.to_s ) end def set_port return true if persisted? self.port = DEFAULT_PORT end def set_quota return true if persisted? self.quota = ConfigurationSetting.client_quota end # validation def fqdn_format regex = /(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(?
<% if @client.is_backed_up? %>

Restore files for "<%= @client.name %>"

-
+
<%= bootstrap_form_tag(url: run_restore_client_path(@client), remote: true, layout: :horizontal, label_col: 'col-xs-4', control_col: 'col-xs-7', html: { id: 'basic-form' } ) do |f| %> -
- -
-

- Restore to most recent backup by leaving date and time blank -

-
-
+ <%= help_block('Restore to most recent backup by leaving date and time blank', + 'col-xs-4', 'col-xs-7') %> <%= f.text_field :restore_date %> <%= f.time_select :restore_time, ignore_date: true, minute_step: 30, prompt: true %> <%= f.select(:fileset, options_from_collection_for_select(@client.file_sets, :id, :file_set)) %> <%= f.text_field :restore_location, placeholder: '/tmp/default_restore' %> +
+ + <%= help_block( + 'Restore and backup clients must have the same encryption key'.html_safe, + 'col-xs-4', 'col-xs-7') %> + + <%= f.select( + :restore_client, + options_from_collection_for_select(@restore_clients, + :id, :name, @client.id), + label: tooltip_label('Restore Client', + 'Client where the backup will be restored to')) %> + +
+
<%= f.submit 'Select Specific Files', id: 'select-files', class: 'btn btn-primary' %>

<%= f.submit 'Restore All Files', class: 'btn btn-warning text-right', data: { confirm: "This will restore all your files" } %>
<% end %> -
+
<% else %>

Can not issue a restore for this client. It does not have any successful backups

<% end %> <%= link_to 'Back to client', client_path(@client), class: 'btn btn-danger', role: 'button' %>
<%= render partial: 'file_selector' %> diff --git a/lib/bacula_handler.rb b/lib/bacula_handler.rb index c111566..5c4e13e 100644 --- a/lib/bacula_handler.rb +++ b/lib/bacula_handler.rb @@ -1,201 +1,205 @@ class BaculaHandler require 'net/scp' attr_accessor :host, :templates, :client, :jobs, :schedules, :filesets # Initializes a BaculaHandler instance. # # Sets `host` and `templates` attributes. # Sets the temporal files that contain the client's configuration # # @param host[Host] A the host instance the the bacula handler will act upon def initialize(host) @host = host @templates = host.job_templates.includes(:fileset, :schedule) @client = get_client_file @jobs = get_jobs_file @schedules = get_schedules_file @filesets = get_filesets_file end # Deploys the host's config to the bacula director by # # * uploading the configuration # * reloadind the bacula director # # Updates the host's status accordingly # # @return [Boolean] false if something went wrong def deploy_config return false unless send_config if reload_bacula if host.bacula_ready? host.set_deployed else host.set_inactive end else host.dispatch || host.redispatch end end # Removes the host's configuration from the bacula director by # # * removing the host's configuration files # * reloading the bacula director # # Updates the host's status accordingly # # @return [Boolean] false if something went wrong def undeploy_config return false unless remove_config host.disable if reload_bacula end # Schedules an immediate backup to the bacula director for the given host and job # # @param job_name[String] the job's name def backup_now(job_name) job = host.job_templates.enabled.find_by(name: job_name) return false unless job command = "echo \"run level=full job=\\\"#{job.name_for_config}\\\" yes\" | #{bconsole}" log(command) exec_with_timeout(command, 2) end # Schedules an immediate restore to the bacula director for the given host. # # @param job_ids[Array] contains the jobs that compose the restore for this fileset # @param file_set_name[String] the fileset that is going to be restored # @param restore_point[String] the restore datetime # @param location[String] the desired restore location - def restore(job_ids, file_set_name, restore_point, location="/tmp/bacula-restore") + # @param restore_client[String] the desired restore client + def restore(job_ids, file_set_name, restore_point, location="/tmp/bacula-restore", restore_client = nil) command = "echo \"restore client=\\\"#{host.name}\\\" " + if restore_client && restore_client != host.name + command << "restoreclient=\\\"#{restore_client}\\\" " + end command << "jobid=#{job_ids.join(',')} " command << "where=\\\"#{location}\\\" " command << "fileset=\\\"#{file_set_name}\\\" " if restore_point command << "before=\\\"#{restore_point}\\\" " else command << "current " end command << "select all done yes\" " command << "| #{bconsole}" log(command) exec_with_timeout(command, 2) end private def get_client_file file = a_tmpfile file.write host.to_bacula_config_array.join("\n") file.close file end def get_jobs_file file = a_tmpfile file.write templates.map(&:to_bacula_config_array).join("\n") file.close file end def get_schedules_file file = a_tmpfile file.write templates.map(&:schedule).uniq.map(&:to_bacula_config_array).join("\n") file.close file end def get_filesets_file file = a_tmpfile file.write templates.map(&:fileset).uniq.map(&:to_bacula_config_array).join("\n") file.close file end def send_config begin Net::SCP.upload!( ssh_settings[:host], ssh_settings[:username], client.path, ssh_settings[:path] + 'clients/' + host.name + '.conf', ssh: { keys: [ssh_settings[:key_file]] } ) Net::SCP.upload!( ssh_settings[:host], ssh_settings[:username], jobs.path, ssh_settings[:path] + 'jobs/' + host.name + '.conf', ssh: { keys: [ssh_settings[:key_file]] } ) Net::SCP.upload!( ssh_settings[:host], ssh_settings[:username], schedules.path, ssh_settings[:path] + 'schedules/' + host.name + '.conf', ssh: { keys: [ssh_settings[:key_file]] } ) Net::SCP.upload!( ssh_settings[:host], ssh_settings[:username], filesets.path, ssh_settings[:path] + 'filesets/' + host.name + '.conf', ssh: { keys: [ssh_settings[:key_file]] } ) rescue return false end true end def remove_config begin Net::SSH.start(ssh_settings[:host], ssh_settings[:username], keys: ssh_settings[:key_file]) do |ssh| ssh.exec!("rm #{ssh_settings[:path]}*/#{host.name}.conf") end rescue return false end true end def reload_bacula command = "echo \"reload quit\" | #{bconsole}" exec_with_timeout(command, 2) end def exec_with_timeout(command, sec) begin Timeout::timeout(sec) do `#{command}` end rescue return false end true end def bconsole "bconsole -c #{Rails.root}/config/bconsole.conf" end def ssh_settings @ssh_settings ||= YAML::load(File.open("#{Rails.root}/config/ssh.yml"))[Rails.env]. symbolize_keys end def a_tmpfile file = Tempfile.new(host.name) file.chmod(0666) file end def log(msg) Rails.logger.warn("[BaculaHandler]: #{msg}") end end diff --git a/lib/bvfs.rb b/lib/bvfs.rb index 6c36d1e..9abe3cc 100644 --- a/lib/bvfs.rb +++ b/lib/bvfs.rb @@ -1,132 +1,139 @@ class Bvfs attr_accessor :client, :jobids, :files, :dirs def initialize(client, jobids) @jobids = jobids.join(',') @client = client end # Fetches the directories that exist in the given directory and # stores the output to the result instance variable # # @param pathid[String|Integer] If nil or omitted, root directory is implied def fetch_dirs(pathid=nil) path = pathid.nil? ? 'path=\"\"':"pathid=#{pathid}" command = pipe_to_bconsole(".bvfs_lsdirs jobid=#{jobids} #{path}") @dirs = exec_command(command) end # Fetches the files that exist in the given directory and # stores the output to the result instance variable # # @param pathid[String|Integer] If nil or omitted, root directory is implied def fetch_files(pathid=nil) path = pathid.nil? ? 'path=\"\"':"pathid=#{pathid}" command = pipe_to_bconsole(".bvfs_lsfiles jobid=#{jobids} #{path}") @files = exec_command(command) end # Extracts the id and name of the bvfs_lsdirs command def extract_dir_id_and_name dirs. split("\n"). select { |x| x[/^(\d+\W){4}.*[^.]/] }. map {|x| s = x.split("\t"); [s.first, s.last.gsub(/(.)\/$/, '\\1')] }. select { |x| !['.', '..'].include?(x.last) }. to_h end # Extracts the id and name of the bvfs_lsfiles command def extract_file_id_and_name files. split("\n"). select { |x| x[/^(\d+\W){4}.*[^.]/] }. map {|x| s = x.split("\t"); [s.third, s.last] }. select { |x| !['.', '..'].include?(x.last) }. to_h end # Updates the bvfs cache for the specific job ids. # This can take some time. Always provide a job id. def update_cache command = pipe_to_bconsole(".bvfs_update jobid=#{jobids}") exec_command(command) end # Handles restore of multiple selected files and directories # # * creates a db table with the needed files # * issues the restore # * cleans up the table # # @param file_ids[Array] the file ids that will be restored # @param location[String] the client's restore location # @param dir_ids[Array] the directory ids that will be restored - def restore_selected_files(file_ids, location = nil, dir_ids = nil) + # @param restore_client[String] the client where the restore will be sent + def restore_selected_files(file_ids, location = nil, dir_ids = nil, restore_client = nil) location ||= '/tmp/bacula_restore/' dir_ids ||= [] dbname = "b2#{client.id}#{(Time.now.to_f * 100).to_i}" shell_command = [ create_restore_db(dbname, file_ids, dir_ids), - restore_command(dbname, location), + restore_command(dbname, location, restore_client), clear_cache ].map { |command| pipe_to_bconsole(command) }.join(' && ') Rails.logger.warn("[BVFS]: #{shell_command}") pid = spawn shell_command Process.detach(pid) end # Issues the bvfs command for cleaning up a temporary db table # # @param dbname[String] the database table's name def purge_db(dbname) exec_command(pipe_to_bconsole(".bvfs_cleanup path=#{dbname}")) end private # Generates the bvfs command needed in order to create a temporary database # that will hold the files that we want to restore. # # @param file_ids[Array] the file ids that will be restored # @param dir_ids[Array] the directory ids that will be restored # # @return [String] bvfs restore command def create_restore_db(dbname, file_ids, dir_ids) params = "jobid=#{jobids} path=#{dbname}" params << " fileid=#{file_ids.join(',')}" if file_ids.any? params << " dirid=#{dir_ids.join(',')}" if dir_ids.any? ".bvfs_restore #{params}" end # Generates the restore command # # @param dbname[String] the name of the db table that has the desired files # @param location[String] the client's restore location + # @param restore_client[String] the client that will receive the restored files # # @return [String] bconsole's restore command - def restore_command(dbname, location) - "restore file=?#{dbname} client=\\\"#{client.name}\\\" where=\\\"#{location}\\\" yes" + def restore_command(dbname, location, restore_client) + command = "restore file=?#{dbname} client=\\\"#{client.name}\\\" where=\\\"#{location}\\\" " + if restore_client && restore_client != client.name + command << "restoreclient=\\\"#{restore_client}\\\" " + end + command << "yes" + command end def clear_cache '.bvfs_clear_cache yes' end def exec_command(command) Rails.logger.warn("[BVFS]: #{command}") `#{command}` end def pipe_to_bconsole(what) "echo \"#{what}\" | #{bconsole}" end def bconsole "bconsole -c #{Rails.root}/config/bconsole.conf" end end