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| %>
-
+ <%= 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