diff --git a/app/models/host.rb b/app/models/host.rb index aaddd59..34ebc4d 100644 --- a/app/models/host.rb +++ b/app/models/host.rb @@ -1,127 +1,145 @@ class Host < ActiveRecord::Base establish_connection Baas::settings[:local_db] FILE_RETENTION_DAYS = 60 JOB_RETENTION_DAYS = 180 CATALOG = 'MyCatalog' AUTOPRUNE = 1 STATUSES = { pending: 0, configured: 1, dispatched: 2, deployed: 3, updated: 4, - redispatched: 5 + redispatched: 5, + for_removal: 6 } belongs_to :client, class_name: :Client, foreign_key: :name, primary_key: :name 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, -> { where(baculized: 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 event :add_configuration do - transition :pending => :configured + transition [:pending, :dispatched] => :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 => :updated + transition [:deployed, :redispatched, :for_removal] => :updated + end + + event :mark_for_removal do + transition [:dispatched, :deployed, :updated, :redispatched] => :for_removal end event :disable do transition all => :pending end end def baculize_config templates = job_templates.enabled.includes(:fileset, :schedule) result = [self] + templates.map {|x| [x, x.fileset, x.schedule] }.flatten.compact.uniq result.map(&:to_bacula_config_array) end def to_bacula_config_array [ "Client {", " Name = #{name}", " Address = #{fqdn}", " FDPort = #{port}", " Catalog = #{CATALOG}", " Password = \"#{password}\"", " File Retention = #{file_retention} days", " Job Retention = #{job_retention} days", " AutoPrune = yes", "}" ] end def auto_prune_human AUTOPRUNE == 1 ? 'yes' : 'no' end def dispatch_to_bacula - return false if not ready? + return false if not needs_dispatch? BaculaHandler.new(self).deploy_config end - def ready? + def needs_dispatch? verified? && (can_dispatch? || can_redispatch?) end + def needs_revoke? + for_removal? + end + + # Handles the host's job changes by updating the host's status + def recalculate + if job_templates(true).enabled.any? + add_configuration || change_deployed_config + else + mark_for_removal || disable + end + end + private # automatic setters def sanitize_name self.name = fqdn end def set_retention self.file_retention = FILE_RETENTION_DAYS self.job_retention = JOB_RETENTION_DAYS 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(enabled: true) } # configurable DEFAULT_OPTIONS = { storage: :File, pool: :Default, messages: :Standard, priority: 10, :'Write Bootstrap' => '"/var/lib/bacula/%c.bsr"' } def to_bacula_config_array ['Job {'] + options_array.map { |x| " #{x}" } + DEFAULT_OPTIONS.map { |k,v| " #{k.capitalize} = #{v}" } + ['}'] end def priority DEFAULT_OPTIONS[:priority] end def enabled_human enabled? ? 'yes' : 'no' end def schedule_human schedule.present? ? schedule.name : '-' end def name_for_config "#{host.name} #{name}" end def save_and_create_restore_job(location) if save_status = save restore_job = JobTemplate.new( host: host, job_type: :restore, fileset: fileset, name: 'Restore ' + name, restore_location: location ) restore_job.save end save_status end private + def notify_host + host.recalculate + end + # Sets the default job_type as backup def set_job_type self.job_type = :backup if job_type.nil? end def options_array result = [ "Name = \"#{name_for_config}\"", "FileSet = \"#{fileset.name_for_config}\"", "Client = \"#{host.name}\"", "Type = \"#{job_type.capitalize}\"" ] if restore? result += ["Where = \"#{restore_location}\""] else result += ["Schedule = \"#{schedule.name_for_config}\""] end result end end diff --git a/spec/factories/host.rb b/spec/factories/host.rb index 2ee44b0..cd9e332 100644 --- a/spec/factories/host.rb +++ b/spec/factories/host.rb @@ -1,22 +1,31 @@ FactoryGirl.define do factory :host do sequence(:fqdn) {|n| "lala.#{n}-#{rand(1000)}.gr" } password 'a strong password' port 1234 file_retention 1 job_retention 2 baculized false trait :configured do status Host::STATUSES[:configured] job_templates { create_list :enabled_job_template, 1 } verified true end trait :updated do status Host::STATUSES[:updated] job_templates { create_list :enabled_job_template, 1 } verified true end + + trait :with_disabled_jobs do + job_templates { create_list(:job_template, 1) } + verified true + end + + trait :with_enabled_jobs do + job_templates { create_list :enabled_job_template, 1 } + end end end diff --git a/spec/models/host_spec.rb b/spec/models/host_spec.rb index 33b3dd7..9ccf187 100644 --- a/spec/models/host_spec.rb +++ b/spec/models/host_spec.rb @@ -1,191 +1,263 @@ require 'spec_helper' describe Host do context 'validates' do it "presence of Password" do expect(Host.new).to have(1).errors_on(:password) end it 'numericality of :port' do expect(Host.new(port: :lala)).to have(2).errors_on(:port) end [:file_retention, :job_retention, :name].each do |field| it "#{field} is set automatically" do host = Host.new(fqdn: 'test') host.valid? expect(host.send(field)).to be_present end end end context 'when fqdn is invalid' do let(:host) { FactoryGirl.build(:host, fqdn: :lala) } it 'has errors' do expect(host).to have(1).errors_on(:fqdn) end end context 'name field' do let(:host) { FactoryGirl.create(:host, name: nil) } it 'is generated by the system' do expect(host.name).to be end end describe '#to_bacula_config_array' do let(:host) { FactoryGirl.create(:host) } it "is a valid client directive" do expect(host.to_bacula_config_array).to include('Client {') expect(host.to_bacula_config_array).to include('}') end it "contains Address directive" do expect(host.to_bacula_config_array).to include(" Address = #{host.fqdn}") end it "contains FDPort directive" do expect(host.to_bacula_config_array).to include(" FDPort = #{host.port}") end it "contains Catalog directive" do expect(host.to_bacula_config_array).to include(" Catalog = #{Host::CATALOG}") end it "contains Password directive" do expect(host.to_bacula_config_array).to include(" Password = \"#{host.password}\"") end it "contains File Retention directive" do expect(host.to_bacula_config_array). to include(" File Retention = #{host.file_retention} days") end it "contains Job Retention directive" do expect(host.to_bacula_config_array). to include(" Job Retention = #{host.job_retention} days") end it "contains AutoPrune directive" do expect(host.to_bacula_config_array).to include(" AutoPrune = yes") end end describe '#baculize_config' do let!(:host) { FactoryGirl.create(:host) } let!(:fileset) { FactoryGirl.create(:fileset, host: host) } let!(:other_fileset) { FactoryGirl.create(:fileset, host: host) } let!(:schedule) { FactoryGirl.create(:schedule) } let!(:other_schedule) { FactoryGirl.create(:schedule) } let!(:enabled_job) do FactoryGirl.create(:job_template, host: host, schedule: schedule, fileset: fileset, enabled: true) end let!(:disabled_job) do FactoryGirl.create(:job_template, host: host, schedule: other_schedule, fileset: other_fileset, enabled: false) end subject { host.baculize_config } it 'includes the client\'s config' do expect(subject).to include(host.to_bacula_config_array) end it 'includes the enabled job template\'s configs' do expect(subject).to include(enabled_job.to_bacula_config_array) expect(subject).to_not include(disabled_job.to_bacula_config_array) end it 'includes the used schedules\'s configs' do expect(subject).to include(schedule.to_bacula_config_array) expect(subject).to_not include(other_schedule.to_bacula_config_array) end it 'includes the used filesets\'s configs' do expect(subject).to include(fileset.to_bacula_config_array) expect(subject).to_not include(other_fileset.to_bacula_config_array) end end describe '#dispatch_to_bacula' do let(:configured_host) { FactoryGirl.create(:host, :configured) } let(:updated_host) { FactoryGirl.create(:host, :updated) } context 'for non verified hosts' do let(:unverified_host) { FactoryGirl.create(:host, :configured) } it 'returns false' do expect(unverified_host.dispatch_to_bacula).to eq(false) end end it 'calls BaculaHandler#deploy_config' do BaculaHandler.any_instance.should_receive(:deploy_config) configured_host.dispatch_to_bacula end context 'when the config does not reach bacula' do before do BaculaHandler.any_instance.should_receive(:send_config) { false } end it 'returns false' do expect(configured_host.dispatch_to_bacula).to eq(false) end it 'does not change the status of a configured host' do expect { configured_host.dispatch_to_bacula }. to_not change { configured_host.reload.status } end it 'does not change the status of an updated host' do expect { updated_host.dispatch_to_bacula }. to_not change { updated_host.reload.status } end end context 'when the config is sent to bacula' do before do BaculaHandler.any_instance.should_receive(:send_config) { true } end context 'and bacula gets reloaded' do before do BaculaHandler.any_instance.should_receive(:reload_bacula) { true } end it 'makes the configured host deployed' do configured_host.dispatch_to_bacula expect(configured_host.reload).to be_deployed end it 'makes the updated host deployed' do updated_host.dispatch_to_bacula expect(updated_host.reload).to be_deployed end end context 'but bacula fails to reload' do before do BaculaHandler.any_instance.should_receive(:reload_bacula) { false } end it 'makes the configured host dispatcheda' do configured_host.dispatch_to_bacula expect(configured_host.reload).to be_dispatched end it 'makes the updated host redispatched' do updated_host.dispatch_to_bacula expect(updated_host.reload).to be_redispatched end end end end + + describe '#recalculate' do + context 'when the host does NOT have enabled jobs' do + let(:host) { FactoryGirl.create(:host, :with_disabled_jobs) } + + context 'and is configured' do + before { host.update_column(:status, Host::STATUSES[:configured]) } + + it 'becomes pending' do + expect { host.recalculate }. + to change { host.reload.human_status_name }.from('configured').to('pending') + end + end + + [:dispatched, :deployed, :updated, :redispatched].each do |status| + context "and is #{status}" do + before { host.update_column(:status, Host::STATUSES[status]) } + + it 'becomes for_removal' do + expect { host.recalculate }. + to change { host.reload.human_status_name }.from(status.to_s).to('for removal') + end + end + end + end + + context 'when host has enabled jobs' do + let(:host) { FactoryGirl.create(:host, :with_enabled_jobs) } + + [:configured, :updated].each do |status| + context "a #{status} host" do + before { host.update_column(:status, Host::STATUSES[status]) } + + it "stays #{status}" do + expect { host.recalculate }.to_not change { host.reload.status } + end + end + end + + context 'a pending host' do + before { host.update_column(:status, Host::STATUSES[:pending]) } + + it 'becomes configured' do + expect { host.recalculate }. + to change { host.reload.human_status_name }. + from('pending').to('configured') + end + end + + context 'a dispatched host' do + before { host.update_column(:status, Host::STATUSES[:dispatched]) } + + it 'becomes configured' do + expect { host.recalculate }. + to change { host.reload.human_status_name }. + from('dispatched').to('configured') + end + end + + [:deployed, :redispatched, :for_removal].each do |status| + context "a #{status} host" do + before { host.update_column(:status, Host::STATUSES[status]) } + + it 'becomes updated' do + expect { host.recalculate }. + to change { host.reload.human_status_name }. + from(host.human_status_name).to('updated') + end + end + end + end + end end diff --git a/spec/models/job_template_spec.rb b/spec/models/job_template_spec.rb index d879f16..d2019fe 100644 --- a/spec/models/job_template_spec.rb +++ b/spec/models/job_template_spec.rb @@ -1,118 +1,185 @@ require 'spec_helper' describe JobTemplate do context 'validates' do it 'name must be present' do expect(JobTemplate.new).to have(1).errors_on(:name) end it 'name must be unique on host\'s scope' do job_1 = FactoryGirl.create(:job_template, name: 'a name') job_2 = FactoryGirl.build(:job_template, name: 'a name') job_3 = FactoryGirl.build(:job_template, name: 'a name', host: job_1.host) expect(job_2).to be_valid expect(job_3).to_not be_valid end it 'fileset_id must be present' do expect(JobTemplate.new).to have(1).errors_on(:fileset_id) end it 'schedule_id must be present' do expect(JobTemplate.new).to have(1).errors_on(:schedule_id) end it 'schedule_id must NOT be present for :restore jobs' do expect(JobTemplate.new(job_type: :restore)).to have(0).errors_on(:schedule_id) end end # automatic assignments context 'when no job_type is given' do let(:job_template) { FactoryGirl.create(:job_template) } it 'sets the job_type to :backup' do expect(job_template).to be_backup end end + context 'when enabling a job' do + + [:pending, :dispatched].each do |status| + context "of a #{status} host" do + let(:host) { FactoryGirl.create(:host) } + let!(:job) { FactoryGirl.create(:job_template, host: host) } + + before { host.update_column(:status, Host::STATUSES[status]) } + + it 'becomes configured' do + expect { + job.enabled = true + job.save + }.to change { + host.reload.human_status_name + }.from(status.to_s).to('configured') + end + end + end + + context 'of a configured host' do + let(:host) { FactoryGirl.create(:host) } + let!(:job) { FactoryGirl.create(:job_template, host: host) } + + before { host.update_column(:status, Host::STATUSES[:configured]) } + + it 'stays configured' do + expect { + job.enabled = true + job.save + }.to_not change { host.reload.human_status_name } + end + end + + context 'of a updated host' do + let(:host) { FactoryGirl.create(:host) } + let!(:job) { FactoryGirl.create(:job_template, host: host) } + + before { host.update_column(:status, Host::STATUSES[:updated]) } + + it 'stays updated' do + expect { + job.enabled = true + job.save + }.to_not change { host.reload.human_status_name } + end + end + + [:deployed, :redispatched].each do |status| + context "of a #{status} host" do + let(:host) { FactoryGirl.create(:host) } + let!(:job) { FactoryGirl.create(:job_template, host: host) } + + before { host.update_column(:status, Host::STATUSES[status]) } + + it 'becomes updated' do + expect { + job.enabled = true + job.save + }.to change { + host.reload.human_status_name + }.from(status.to_s).to('updated') + end + end + end + end + describe '#to_bacula_config_array' do let(:job_template) { FactoryGirl.create(:job_template) } subject { job_template.to_bacula_config_array } it 'has a Job structure' do expect(subject.first).to eq('Job {') expect(subject.last).to eq('}') end JobTemplate::DEFAULT_OPTIONS.each do |k, v| it "assigns #{k.capitalize} param" do expect(subject).to include(" #{k.capitalize} = #{v}") end end it 'assigns Name param prefixed with the host\'s name' do expect(subject).to include(" Name = \"#{job_template.name_for_config}\"") end it 'assigns FileSet param' do expect(subject).to include(" FileSet = \"#{job_template.fileset.name_for_config}\"") end it 'assigns Client param' do expect(subject).to include(" Client = \"#{job_template.host.name}\"") end it 'assigns Type param' do expect(subject).to include(" Type = \"#{job_template.job_type.capitalize}\"") end it 'assigns Schedule param' do expect(subject).to include(" Schedule = \"#{job_template.schedule.name_for_config}\"") end context 'for a restore job' do let(:restore_job) { FactoryGirl.create(:job_template, :restore) } subject { restore_job.to_bacula_config_array } it 'does not assign a Schedule param' do expect(subject).to_not include(" Schedule = \"#{restore_job.schedule.name}\"") end it 'assigns Where param' do expect(subject).to include(" Where = \"#{restore_job.restore_location}\"") end end end describe '#save_and_create_restore_job' do let(:host) { FactoryGirl.create(:host) } let(:backup_job_template) do FactoryGirl.build(:job_template, job_type: nil, host: host) end it 'calls save' do backup_job_template.should_receive(:save) backup_job_template.save_and_create_restore_job('/foo') end it 'creates a restore job for the same host' do expect { backup_job_template.save_and_create_restore_job('/foo') }. to change { host.job_templates.restore.count }.by(1) end it 'creates a restore job for fileset' do backup_job_template.save_and_create_restore_job('/foo') expect(host.job_templates.restore.pluck(:fileset_id)). to eq([backup_job_template.fileset_id]) end it 'sets the correct restore location' do backup_job_template.save_and_create_restore_job('/foo') expect(host.job_templates.restore.pluck(:restore_location)). to eq(['/foo']) end end end