Skip to content

Commit

Permalink
Reworking and CircleCI updates
Browse files Browse the repository at this point in the history
  • Loading branch information
KashifSaadat committed Jan 4, 2019
1 parent fe5e2d2 commit 3762f68
Show file tree
Hide file tree
Showing 24 changed files with 509 additions and 234 deletions.
18 changes: 18 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
version: 2
jobs:
test:
docker:
- image: ruby:2.5.0-alpine
steps:
- checkout
- run:
name: Install build dependencies
command: apk add -U g++ make
- run:
name: Install ruby dependencies
command: bundle install --deployment
- run:
name: Run code analyser
command: bundle exec rubocop
- run:
name: Run RSpec
command: bundle exec rspec spec/
build:
machine: true
steps:
Expand Down Expand Up @@ -31,6 +48,7 @@ workflows:
version: 2
build:
jobs:
- test
- build
push_latest:
jobs:
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.DS_Store
.bundle/*
src/.bundle/*
src/vendor/*
vendor/*
.DS_Store
2 changes: 2 additions & 0 deletions .rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
--color
--require spec_helper
13 changes: 13 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Metrics/AbcSize:
Enabled: false

Metrics/BlockLength:
Enabled: true
Exclude:
- spec/**/*

Metrics/LineLength:
Enabled: false

Metrics/MethodLength:
Enabled: false
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM ruby:2.6.0-alpine
FROM ruby:2.5.0-alpine
LABEL maintainer="info@appvia.io"
LABEL source="https://github.com/appvia/rds-scheduler"

Expand All @@ -8,7 +8,7 @@ WORKDIR /app
RUN apk update && apk upgrade

# Copy application files into image
COPY src /app/
COPY lib Gemfile Gemfile.lock /app/

# Create a non-root user and set file permissions
RUN addgroup -S app \
Expand All @@ -19,7 +19,7 @@ RUN addgroup -S app \
USER 1000

# Fetch dependencies
RUN bundle install --gemfile=Gemfile --path=vendor/bundle
RUN bundle install --deployment --without test

# Set the run command
CMD ["ruby", "run.rb"]
17 changes: 17 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

source 'https://rubygems.org'

ruby '2.5.0'

gem 'activesupport'
gem 'aws-sdk-rds'
gem 'bundler'
gem 'logger'
gem 'tzinfo'
gem 'tzinfo-data'

group :test do
gem 'rspec'
gem 'rubocop'
end
80 changes: 80 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (5.2.2)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
tzinfo (~> 1.1)
ast (2.4.0)
aws-eventstream (1.0.1)
aws-partitions (1.127.0)
aws-sdk-core (3.44.1)
aws-eventstream (~> 1.0)
aws-partitions (~> 1.0)
aws-sigv4 (~> 1.0)
jmespath (~> 1.0)
aws-sdk-rds (1.42.0)
aws-sdk-core (~> 3, >= 3.39.0)
aws-sigv4 (~> 1.0)
aws-sigv4 (1.0.3)
concurrent-ruby (1.1.4)
diff-lcs (1.3)
i18n (1.4.0)
concurrent-ruby (~> 1.0)
jaro_winkler (1.5.1)
jmespath (1.4.0)
logger (1.3.0)
minitest (5.11.3)
parallel (1.12.1)
parser (2.5.3.0)
ast (~> 2.4.0)
powerpack (0.1.2)
rainbow (3.0.0)
rspec (3.8.0)
rspec-core (~> 3.8.0)
rspec-expectations (~> 3.8.0)
rspec-mocks (~> 3.8.0)
rspec-core (3.8.0)
rspec-support (~> 3.8.0)
rspec-expectations (3.8.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.8.0)
rspec-mocks (3.8.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.8.0)
rspec-support (3.8.0)
rubocop (0.62.0)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1)
powerpack (~> 0.1)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
unicode-display_width (~> 1.4.0)
ruby-progressbar (1.10.0)
thread_safe (0.3.6)
tzinfo (1.2.5)
thread_safe (~> 0.1)
tzinfo-data (1.2018.9)
tzinfo (>= 1.0.0)
unicode-display_width (1.4.1)

PLATFORMS
ruby

DEPENDENCIES
activesupport
aws-sdk-rds
bundler
logger
rspec
rubocop
tzinfo
tzinfo-data

RUBY VERSION
ruby 2.5.0p0

BUNDLED WITH
1.16.1
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ docker run --rm -t -v ~/.aws:/home/app/.aws:ro -e AWS_PROFILE=my-aws-profile qua

The following environment variables can be passed:
- `DRY_RUN`: Don't make any changes to RDS instances, just prints what actions would be performed to stdout (default: `false`)
- `LOOP_INTERVAL`: How frequently (in seconds) to loop and perform checks on the RDS instance schedules (default: `30`)
- `LOOP_INTERVAL_SECS`: How frequently (in seconds) to loop and perform checks on the RDS instance schedules (default: `60`)
- `RUN_ONCE`: Loop through RDS instances only once and exit the script (default: `false`)
- `TAG_UPTIME_SCHEDULE`: AWS Tag name on the RDS instances containing a time definition (default: `appvia.io/rds-scheduler/uptime-schedule`)

### Kubernetes

Expand All @@ -53,8 +54,14 @@ If you're using **[rbenv](https://github.com/rbenv/rbenv)**:
# Install Ruby v2.5.0
rbenv install 2.5.0
# Install / Update bundler
gem install bundler
# Download dependencies
bundle install --gemfile=src/Gemfile --path=src/vendor/bundle
bundle install --path=lib/vendor/bundle --deployment --without test
# Copy Gemfiles to the lib directory
cp Gemfile* lib/
```

Example deployment files are located in the [./examples/terraform](./examples/terraform) directory. The Lambda Function is configured to trigger via a CloudWatch Event Rule and execute every 5 minutes.
Expand Down
2 changes: 1 addition & 1 deletion examples/terraform/lambda.tf
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Zip up the rds-scheduler code and dependencies
data "archive_file" "rds-scheduler" {
type = "zip"
source_dir = "../../src"
source_dir = "../../lib"
output_path = "build/rds-scheduler.zip"
}

Expand Down
8 changes: 8 additions & 0 deletions lib/lambda.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative 'rds_scheduler'

def main(*)
RDSScheduler.new.execute
end
44 changes: 44 additions & 0 deletions lib/rds_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

require 'aws-sdk-rds'

# Make calls to the AWS RDS API
class RDSHelper
def initialize(dry_run: false, logger: nil)
@rds_client = Aws::RDS::Client.new
@logger = logger
@dry_run = dry_run
end

def db_instances
@rds_client.describe_db_instances.db_instances
end

def db_tags(db_arn)
@rds_client.list_tags_for_resource(
resource_name: db_arn
).tag_list
end

def start_db_instance(db_name, db_status, db_schedule)
if db_status.eql? 'stopped'
@logger.info "Starting DB Instance '#{db_name}' (schedule: '#{db_schedule}', dry_run: #{@dry_run})"
@rds_client.start_db_instance(db_instance_identifier: db_name) unless @dry_run
elsif db_status.eql? 'available'
@logger.info "DB Instance '#{db_name}' is currently available (schedule: '#{db_schedule}')"
else
@logger.warn "DB Instance '#{db_name}' is not in a stopped state, not taking action (status: #{db_status}, schedule: '#{db_schedule}')"
end
end

def stop_db_instance(db_name, db_status, db_schedule)
if %w[stopping stopped].include?(db_status)
@logger.info "DB Instance '#{db_name}' is currently stopped (status: #{db_status}, schedule: '#{db_schedule}')"
elsif db_status.eql? 'available'
@logger.info "Stopping DB Instance '#{db_name}' (schedule: '#{db_schedule}', dry_run: #{@dry_run})"
@rds_client.stop_db_instance(db_instance_identifier: db_name) unless @dry_run
else
@logger.warn "DB Instance '#{db_name}' is not in a running state, not taking action (status: #{db_status}, schedule: '#{db_schedule}')"
end
end
end
67 changes: 67 additions & 0 deletions lib/rds_scheduler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# frozen_string_literal: true

require 'bundler'
Bundler.setup
require 'aws-sdk-rds'
require 'logger'
require_relative 'rds_helper'
require_relative 'time_schedule_parser'

# Manage uptime schedules for RDS Instances
class RDSScheduler
def initialize
@logger = Logger.new(STDOUT)
$stdout.sync = true
dry_run = ENV.fetch('DRY_RUN', false).to_s.casecmp('true').zero?
@loop_interval = ENV.fetch('LOOP_INTERVAL_SECS', 60).to_i
@rds_client = RDSHelper.new(dry_run: dry_run, logger: @logger)
@run_once = ENV.fetch('RUN_ONCE', false).to_s.casecmp('true').zero?
@tag_uptime_schedule = ENV.fetch('TAG_UPTIME_SCHEDULE', 'appvia.io/rds-scheduler/uptime-schedule')
@time_parser = TimeScheduleParser.new
end

def execute
loop do
@logger.info 'Retrieving DB instances...'
dbs = @rds_client.db_instances

dbs.each do |rds|
tags = @rds_client.db_tags(rds.db_instance_arn)

db_schedule = false
tags.each do |tag|
(db_schedule = tag.value) && break if tag.key.eql?(@tag_uptime_schedule)
end

process_schedule(rds.db_instance_identifier, rds.db_instance_status, db_schedule)
end

break if @run_once

@logger.info("Sleeping for #{@loop_interval} seconds...")
sleep(@loop_interval)
end
end

def process_schedule(db_name, db_status, db_schedule)
if db_schedule
begin
parsed_schedule = @time_parser.parse_schedule(db_schedule)
rescue StandardError => e
@logger.warn "DB Instance '#{db_name}' has an invalid schedule: #{e.message}"
else
begin
if @time_parser.schedule_active?(parsed_schedule)
@rds_client.start_db_instance(db_name, db_status, db_schedule)
else
@rds_client.stop_db_instance(db_name, db_status, db_schedule)
end
rescue TimeScheduleParser::TimezoneInvalid => e
@logger.error "Error processing Time Schedule for DB Instance '#{db_name}': #{e.message}"
end
end
else
@logger.info "DB Instance '#{db_name}' has no schedule defined (status: #{db_status})"
end
end
end
6 changes: 6 additions & 0 deletions lib/run.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative 'rds_scheduler'

RDSScheduler.new.execute
52 changes: 52 additions & 0 deletions lib/time_schedule_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

require 'active_support/core_ext/numeric/time'
require 'tzinfo'

# Parse time definition strings for validity
class TimeScheduleParser
WEEKDAYS = %w[MON TUE WED THU FRI SAT SUN].freeze
TimeSchedule = Struct.new(:day_from, :day_to, :hour_from, :minute_from, :hour_to, :minute_to, :timezone)

def time_schedule(opts)
TimeSchedule.new(opts[:day_from], opts[:day_to], opts[:hour_from], opts[:minute_from], opts[:hour_to], opts[:minute_to], opts[:timezone])
end

def parse_schedule(schedule)
# Example: "Mon-Fri 09:00-17:30 Europe/London"
regexp = %r{^([a-zA-Z]{3})-([a-zA-Z]{3}) (\d\d):(\d\d)-(\d\d):(\d\d) ([a-zA-Z/_]+)$}
matches = schedule.match(regexp)

raise 'Schedule does not match regex' if matches.nil?
raise TimeScheduleParser::TimezoneInvalid, "Timezone is invalid: '#{matches.captures[6]}'" unless TZInfo::Timezone.all_identifiers.include?(matches.captures[6])

day_from = matches.captures[0].upcase
day_to = matches.captures[1].upcase
raise "Day Range is invalid: '#{day_from}-#{day_to}'" unless ([day_from, day_to] - WEEKDAYS).empty?

time_schedule(
day_from: day_from,
day_to: day_to,
hour_from: matches.captures[2].to_i,
minute_from: matches.captures[3].to_i,
hour_to: matches.captures[4].to_i,
minute_to: matches.captures[5].to_i,
timezone: matches.captures[6]
)
end

def schedule_active?(schedule)
begin
current_time = Time.now.in_time_zone(schedule[:timezone])
rescue StandardError
raise TimeScheduleParser::TimezoneInvalid, "Current time could not be computed with the given timezone: '#{schedule[:timezone]}'"
end

day_matches = WEEKDAYS.index(current_time.strftime('%a').upcase).between?(WEEKDAYS.index(schedule[:day_from]), WEEKDAYS.index(schedule[:day_to]))
current_minutes = (current_time.hour * 60) + current_time.min
time_matches = current_minutes.between?((schedule[:hour_from] * 60) + schedule[:minute_from], (schedule[:hour_to] * 60) + schedule[:minute_to])
day_matches && time_matches
end

class TimezoneInvalid < StandardError; end
end
Loading

0 comments on commit 3762f68

Please sign in to comment.