diff --git a/app/controllers/clients_controller.rb b/app/controllers/clients_controller.rb index 354d912..f269754 100644 --- a/app/controllers/clients_controller.rb +++ b/app/controllers/clients_controller.rb @@ -1,57 +1,79 @@ class ClientsController < ApplicationController before_action :require_logged_in - before_action :set_client, only: [:show, :jobs, :logs, :stats, :users] + before_action :fetch_client, only: [:show, :jobs, :logs, :stats, :users, :restore, :run_restore] before_action :fetch_logs, only: [:logs] # 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 end + # GET /clients/1/restore + def restore + 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] + if location.nil? || fileset.nil? || !@client.host.restore(fileset, location) + flash[:error] = 'Something went wrong, try again later' + else + flash[:success] = + "Restore job issued successfully, files will be soon available in #{location}" + end + + redirect_to client_path(@client) + end + private - def set_client + def fetch_client @client = Client.for_user(current_user.id).find(params[:id]) @client_ids = [@client.id] end def fetch_jobs_info @stats = JobStats.new(@client_ids) end def get_charts days_ago = params.fetch(:days_back, 7).to_i rescue 7 @job_status = ChartGenerator.job_statuses(@client_ids, days_ago) @job_stats = ChartGenerator.job_stats(@client_ids, days_ago - 1) end end diff --git a/app/controllers/hosts_controller.rb b/app/controllers/hosts_controller.rb index 0b6e1b6..c4677df 100644 --- a/app/controllers/hosts_controller.rb +++ b/app/controllers/hosts_controller.rb @@ -1,133 +1,113 @@ class HostsController < ApplicationController before_action :require_logged_in before_action :fetch_host, only: [:show, :edit, :update, :destroy, :submit_config, - :revoke, :restore, :run_restore, :disable] + :revoke, :disable] before_action :fetch_hosts_of_user, only: [:new, :edit, :create] # GET /hosts/new def new @host = Host.new @host.port = 9102 end # POST /hosts def create @host = Host.new(fetch_params) @host.verified = current_user.needs_host_list? if user_can_add_this_host? && @host.save flash[:success] = 'Host created successfully' current_user.hosts << @host redirect_to host_path @host else flash[:error] = 'Host was not created' render :new end end # GET /hosts/1 def show @schedules = @host.job_templates.map(&:schedule) @filesets = @host.job_templates.map(&:fileset) end # GET /hosts/1/edit def edit; end # PATCH /hosts/1 def update updates = fetch_params.slice(:port, :password) if updates.present? && @host.update_attributes(updates) @host.recalculate flash[:success] = 'Host updated successfully. You must update your file deamon accordingly.' redirect_to host_path @host else render :edit end end # DELETE /hosts/1 def destroy if @host.destroy flash[:success] = 'Host destroyed successfully' else flash[:error] = 'Host not destroyed' end redirect_to root_path end # POST /hosts/1/disable def disable if @host.disable_jobs_and_update flash[:success] = 'Client disabled' else flash[:error] = 'Something went wrong, try again later' end redirect_to host_path(@host) end # POST /hosts/1/submit_config def submit_config if @host.dispatch_to_bacula flash[:success] = 'Host configuration sent to Bacula successfully' else flash[:error] = 'Something went wrong, try again later' end redirect_to host_path(@host) end # DELETE /hosts/1/revoke def revoke if @host.remove_from_bacula flash[:success] = 'Host configuration removed from Bacula successfully' else flash[:error] = 'Something went wrong, try again later' end redirect_to root_path end - # GET /hosts/1/restore - def restore - if !@host.restorable? - flash[:error] = "Can not issue a restore for this client" - redirect_to @host.client.present? ? client_path(@host.client) : root_path - end - end - - # POST /hosts/1/run_estore - def run_restore - location = params[:restore_location] - if location.present? && @host.restore(location) - flash[:success] = "Restore job issued successfully, files will be soon available in #{location}" - else - flash[:error] = 'Something went wrong, try again later' - end - - redirect_to client_path(@host.client) - end - private def fetch_hosts_of_user return if not current_user.needs_host_list? @hosts_of_user = session[:vms] - current_user.hosts.pluck(:fqdn) end def fetch_host @host = current_user.hosts.includes(job_templates: [:fileset, :schedule]).find(params[:id]) end def fetch_params params.require(:host).permit(:fqdn, :port, :password) end def user_can_add_this_host? !current_user.needs_host_list? || @hosts_of_user.include?(@host.fqdn) end end diff --git a/app/models/client.rb b/app/models/client.rb index e8a9a36..d5ad86f 100644 --- a/app/models/client.rb +++ b/app/models/client.rb @@ -1,102 +1,127 @@ # Bacula Client class. # All hosts that are getting backed up with Bacula have a Client entry, with # attributes concerning the Client. class Client < ActiveRecord::Base self.table_name = :Client self.primary_key = :ClientId alias_attribute :name, :Name alias_attribute :uname, :Uname alias_attribute :auto_prune, :AutoPrune alias_attribute :file_retention, :FileRetention alias_attribute :job_retention, :JobRetention has_many :jobs, foreign_key: :ClientId has_one :host, foreign_key: :name, primary_key: :Name scope :for_user, ->(user_id) { joins(host: :users).where(users: { id: user_id }) } DAY_SECS = 60 * 60 * 24 # Fetches the client's job_templates that are already persisted to # Bacula's configuration # # @return [ActiveRecord::Relation] of `JobTemplate` def persisted_jobs host.job_templates.where(baculized: true).includes(:fileset, :schedule) end # Fetches the client's performed jobs in reverse chronological order # # @return [ActiveRecord::Relation] of `Job` def recent_jobs jobs.order(EndTime: :desc).includes(:file_set) end # Helper method. It shows the client's job retention, # (which is expressed in seconds) in days. # # @return [Integer] def job_retention_days job_retention / DAY_SECS end # Helper method. It shows the client's file retention, # (which is expressed in seconds) in days. # # @return [Integer] def file_retention_days file_retention / DAY_SECS end # Helper method for auto_prune # # @return [String] 'yes' or 'no' def auto_prune_human auto_prune == 1 ? 'yes' : 'no' end # Helper method for displayin the last job's datetime in a nice format. def last_job_date_formatted if job_time = jobs.backup_type.last.try(:end_time) I18n.l(job_time, format: :long) end end # Shows if a client has any backup jobs to Bacule config # # @return [Boolean] def is_backed_up? jobs.backup_type.any? end # Shows the total file size of the jobs that run for a specific client # # @return [Integer] Size in Bytes def backup_jobs_size jobs.backup_type.map(&:job_bytes).sum end # Shows the total files' count for the jobs that run for a specific client # # @return [Integer] File count def files_count jobs.map(&:job_files).sum end # Fetches the client's jobs that are running at the moment # # @return [Integer] def running_jobs jobs.running.count end # Displays the bacula config that is generated from the client's # host # # @return [String] def bacula_config return unless host host.baculize_config.join("\n") end + + # Fetches the job ids that will construct the desired restore + # + # @param fileset_id[Integer] the fileset + # @param restore_point[Datetime] the restore point + # + # @return [Array] of ids + def get_job_ids(file_set_id, restore_point) + job_ids = {} + backup_jobs = jobs.backup_type.terminated.where(file_set_id: file_set_id) + backup_jobs = backup_jobs.where('EndTime < ?', restore_point) if restore_point + + job_ids['F'] = backup_jobs.where(level: 'F').pluck(:JobId).last + return [] if job_ids['F'].nil? + job_ids['D'] = backup_jobs.where(level: 'D').where("JobId > ?", job_ids['F']).pluck(:JobId).last + job_ids['I'] = backup_jobs.where(level: 'I'). + where("JobId > ?", job_ids['D'] || job_ids['F'] ).pluck(:JobId) + + job_ids.values.flatten.compact + end + + # Fetches the bacula filesets that are associated with the client + def file_sets + FileSet.joins(:jobs).where(Job: { JobId: job_ids }).uniq + end end diff --git a/app/models/host.rb b/app/models/host.rb index f913c96..62dae9a 100644 --- a/app/models/host.rb +++ b/app/models/host.rb @@ -1,291 +1,295 @@ # 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 STATUSES = { pending: 0, configured: 1, dispatched: 2, deployed: 3, updated: 4, redispatched: 5, for_removal: 6, inactive: 7 } has_many :ownerships has_many :users, through: :ownerships, inverse_of: :hosts 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, numericality: true validates :fqdn, presence: true, uniqueness: true validate :fqdn_format scope :not_baculized, -> { joins("left join Client on Client.Name = hosts.name").where(Client: { Name: nil }) } scope :unverified, -> { where(verified: false) } before_validation :set_retention, :unset_baculized, :sanitize_name 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 [:deployed, :dispatched, :updated, :redispatched] => :inactive end event :disable do transition all => :pending end end # Constructs the final Bacula configuration for the host by appending configs for # # * Client # * Jobs # * Schedules # * Filesets # # by calling their `to_bacula_config_array` methods. # # @return [Array] containing each element's configuration line by line def baculize_config templates = job_templates.includes(:fileset, :schedule) result = [self] + templates.map {|x| [x, x.fileset, x.schedule] }.flatten.compact.uniq result.map(&:to_bacula_config_array) end # Constructs the final Bacula configuration for the host by appending configs for # # * Client # * Jobs # * Schedules # * Filesets # # by calling their `to_bacula_config_array` methods. # # It hides the password. # # @return [Array] containing each element's configuration line by line def baculize_config_no_pass baculize_config.join("\n").gsub(/Password = ".*"$/, 'Password = "*************"') end # Constructs an array where each element is a line for the Client's bacula config # # @return [Array] def to_bacula_config_array [ "Client {", " Name = #{name}", " Address = #{fqdn}", " FDPort = #{port}", " Catalog = #{client_settings[:catalog]}", " Password = \"#{password}\"", " File Retention = #{file_retention} #{file_retention_period_type}", " Job Retention = #{job_retention} #{job_retention_period_type}", " AutoPrune = #{auto_prune_human}", "}" ] 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 def remove_from_bacula return false unless needs_revoke? bacula_handler.undeploy_config end # Restores a host's backup to a preselected location # + # @param fileset_id[Integer] the desired fileset # @param location[String] the desired restore location - def restore(location) + # @param restore_point[Datetime] the desired restore_point datetime + def restore(file_set_id, location, restore_point=nil) return false if not restorable? - bacula_handler.restore(location) + 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, location) 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 # 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 } end end # Determines if a host can issue a restore job. # # @returns [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 save 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 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 # validation def fqdn_format regex = /(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(? { where(job_status: 'R') } + scope :terminated, -> { where(job_status: 'T') } scope :backup_type, -> { where(type: 'B') } scope :restore_type, -> { where(type: 'R') } HUMAN_STATUS = { 'A' => 'Canceled by user', 'B' => 'Blocked', 'C' => 'Created, not yet running', 'D' => 'Verify found differences', 'E' => 'Terminated with errors', 'F' => 'Waiting for Client', 'M' => 'Waiting for media mount', 'R' => 'Running', 'S' => 'Waiting for Storage daemon', 'T' => 'Completed successfully', 'a' => 'SD despooling attributes', 'c' => 'Waiting for client resource', 'd' => 'Waiting on maximum jobs', 'e' => 'Non-fatal error', 'f' => 'Fatal error', 'i' => 'Doing batch insert file records', 'j' => 'Waiting for job resource', 'm' => 'Waiting for new media', 'p' => 'Waiting on higher priority jobs', 's' => 'Waiting for storage resource', 't' => 'Waiting on start time' } paginates_per 20 def level_human { 'F' => 'Full', 'D' => 'Differential', 'I' => 'Incremental' }[level] end def status_human HUMAN_STATUS[job_status] end def fileset file_set.try(:file_set) || '-' end def start_time_formatted if start_time I18n.l(start_time, format: :long) end end def end_time_formatted if end_time I18n.l(end_time, format: :long) end end end diff --git a/app/views/clients/_client_details.html.erb b/app/views/clients/_client_details.html.erb index da00b57..809241e 100644 --- a/app/views/clients/_client_details.html.erb +++ b/app/views/clients/_client_details.html.erb @@ -1,50 +1,50 @@
Name | <%= @client.name %> |
Uname | <%= @client.uname %> |
Active Jobs | <%= @client.running_jobs %> |
Last Backup | <%= @client.last_job_date_formatted %> |
File Retention | <%= @client.file_retention_days %> days |
Job Retention | <%= @client.job_retention_days %> days |
Total Space Used | <%= number_to_human_size @client.backup_jobs_size %> |
Files count | <%= number_by_magnitude(@client.files_count) %> |
Auto Prune | <%= @client.auto_prune_human %> |
<%= restore_point %>
+Can not issue a restore for this host. It has no successful backups
+Can not issue a restore for this host. It is either not deployed or has no successful backups
-