diff --git a/README.md b/README.md index 9d38dfae..9c91801d 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ Commands: aptible login # Log in to Aptible aptible logs [--app APP | --database DATABASE] # Follows logs from a running app or database aptible logs_from_archive --bucket NAME --region REGION --stack NAME [ --decryption-keys ONE [OR MORE] ] [ --download-location LOCATION ] [ [ --string-matches ONE [OR MORE] ] | [ --app-id ID | --database-id ID | --endpoint-id ID | --container-id ID ] [ --start-date YYYY-MM-DD --end-date YYYY-MM-DD ] ] --bucket=BUCKET --region=REGION --stack=STACK # Retrieves container logs from an S3 archive in your own AWS account. You must provide your AWS credentials via the environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY + aptible maintenance:apps # List Apps impacted by maintenance schedules where restarts are required + aptible maintenance:dbs # List Databases impacted by maintenance schedules where restarts are required aptible metric_drain:create:datadog HANDLE --api_key DATADOG_API_KEY --site DATADOG_SITE --environment ENVIRONMENT # Create a Datadog Metric Drain aptible metric_drain:create:influxdb HANDLE --db DATABASE_HANDLE --environment ENVIRONMENT # Create an InfluxDB Metric Drain aptible metric_drain:create:influxdb:custom HANDLE --username USERNAME --password PASSWORD --url URL_INCLUDING_PORT --db INFLUX_DATABASE_NAME --environment ENVIRONMENT # Create an InfluxDB Metric Drain diff --git a/aptible-cli.gemspec b/aptible-cli.gemspec index e39ffeeb..89ad72c0 100644 --- a/aptible-cli.gemspec +++ b/aptible-cli.gemspec @@ -21,7 +21,7 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.add_dependency 'aptible-resource', '~> 1.1' - spec.add_dependency 'aptible-api', '~> 1.2' + spec.add_dependency 'aptible-api', '~> 1.4' spec.add_dependency 'aptible-auth', '~> 1.2.4' spec.add_dependency 'aptible-billing', '~> 1.0' spec.add_dependency 'thor', '~> 0.20.0' diff --git a/lib/aptible/cli/agent.rb b/lib/aptible/cli/agent.rb index c7ecaba3..61cf2534 100644 --- a/lib/aptible/cli/agent.rb +++ b/lib/aptible/cli/agent.rb @@ -21,7 +21,9 @@ require_relative 'helpers/config_path' require_relative 'helpers/log_drain' require_relative 'helpers/metric_drain' +require_relative 'helpers/date_helpers' require_relative 'helpers/s3_log_helpers' +require_relative 'helpers/maintenance' require_relative 'subcommands/apps' require_relative 'subcommands/config' @@ -39,6 +41,7 @@ require_relative 'subcommands/endpoints' require_relative 'subcommands/log_drain' require_relative 'subcommands/metric_drain' +require_relative 'subcommands/maintenance' module Aptible module CLI @@ -49,6 +52,7 @@ class Agent < Thor include Helpers::Ssh include Helpers::System include Helpers::ConfigPath + include Helpers::DateHelpers include Subcommands::Apps include Subcommands::Config include Subcommands::DB @@ -65,6 +69,7 @@ class Agent < Thor include Subcommands::Endpoints include Subcommands::LogDrain include Subcommands::MetricDrain + include Subcommands::Maintenance # Forward return codes on failures. def self.exit_on_failure? diff --git a/lib/aptible/cli/helpers/date_helpers.rb b/lib/aptible/cli/helpers/date_helpers.rb new file mode 100644 index 00000000..5f9aad36 --- /dev/null +++ b/lib/aptible/cli/helpers/date_helpers.rb @@ -0,0 +1,26 @@ +module Aptible + module CLI + module Helpers + module DateHelpers + def utc_date(date_string) + t_fmt = '%Y-%m-%d %Z' + Time.strptime("#{date_string} UTC", t_fmt) + rescue ArgumentError + raise Thor::Error, 'Please provide dates in YYYY-MM-DD format' + end + + def utc_datetime(datetime_string) + Time.parse("#{datetime_string}Z") + rescue ArgumentError + nil + end + + def utc_string(datetime_string) + Time.parse("#{datetime_string}Z").utc + rescue ArgumentError + nil + end + end + end + end +end diff --git a/lib/aptible/cli/helpers/maintenance.rb b/lib/aptible/cli/helpers/maintenance.rb new file mode 100644 index 00000000..64fb8b88 --- /dev/null +++ b/lib/aptible/cli/helpers/maintenance.rb @@ -0,0 +1,19 @@ +require 'aptible/api' + +module Aptible + module CLI + module Helpers + module Maintenance + include Helpers::Token + + def maintenance_apps + Aptible::Api::MaintenanceApp.all(token: fetch_token) + end + + def maintenance_databases + Aptible::Api::MaintenanceDatabase.all(token: fetch_token) + end + end + end + end +end diff --git a/lib/aptible/cli/helpers/s3_log_helpers.rb b/lib/aptible/cli/helpers/s3_log_helpers.rb index 4d22f099..5c2fb910 100644 --- a/lib/aptible/cli/helpers/s3_log_helpers.rb +++ b/lib/aptible/cli/helpers/s3_log_helpers.rb @@ -5,6 +5,8 @@ module Aptible module CLI module Helpers module S3LogHelpers + include Helpers::DateHelpers + def ensure_aws_creds cred_errors = [] unless ENV['AWS_ACCESS_KEY_ID'] @@ -190,19 +192,6 @@ def time_match?(time_range, start_timestamp, end_timestamp) true end - def utc_date(date_string) - t_fmt = '%Y-%m-%d %Z' - Time.strptime("#{date_string} UTC", t_fmt) - rescue ArgumentError - raise Thor::Error, 'Please provide dates in YYYY-MM-DD format' - end - - def utc_datetime(datetime_string) - Time.parse("#{datetime_string}Z") - rescue ArgumentError - nil - end - def encryption_key(filesum, possible_keys) # The key can be determined from the sum possible_keys.each do |k| diff --git a/lib/aptible/cli/resource_formatter.rb b/lib/aptible/cli/resource_formatter.rb index b69da3e3..84e2e7f3 100644 --- a/lib/aptible/cli/resource_formatter.rb +++ b/lib/aptible/cli/resource_formatter.rb @@ -2,6 +2,8 @@ module Aptible module CLI module ResourceFormatter class << self + include Helpers::DateHelpers + NO_NESTING = Object.new.freeze def inject_backup(node, backup, include_db: false) @@ -235,6 +237,28 @@ def inject_metric_drain(node, metric_drain, account) attach_account(node, account) end + def inject_maintenance( + node, + command_prefix, + maintenance_resource, + account + ) + node.value('id', maintenance_resource.id) + raw_start, raw_end = maintenance_resource.maintenance_deadline + window_start = utc_string(raw_start) + window_end = utc_string(raw_end) + label = "#{maintenance_resource.handle} between #{window_start} "\ + "and #{window_end}" + restart_command = "#{command_prefix}"\ + " #{maintenance_resource.handle}"\ + " --environment #{account.handle}" + node.value('label', label) + node.value('handle', maintenance_resource.handle) + node.value('restart_command', restart_command) + + attach_account(node, account) + end + private def attach_account(node, account) diff --git a/lib/aptible/cli/subcommands/logs.rb b/lib/aptible/cli/subcommands/logs.rb index 15563d6a..4cb7ad02 100644 --- a/lib/aptible/cli/subcommands/logs.rb +++ b/lib/aptible/cli/subcommands/logs.rb @@ -10,7 +10,7 @@ def self.included(thor) thor.class_eval do include Helpers::Operation include Helpers::AppOrDatabase - include Helpers::S3LogHelpers + include Helpers::DateHelpers desc 'logs [--app APP | --database DATABASE]', 'Follows logs from a running app or database' diff --git a/lib/aptible/cli/subcommands/maintenance.rb b/lib/aptible/cli/subcommands/maintenance.rb new file mode 100644 index 00000000..9cf9a165 --- /dev/null +++ b/lib/aptible/cli/subcommands/maintenance.rb @@ -0,0 +1,97 @@ +module Aptible + module CLI + module Subcommands + module Maintenance + def self.included(thor) + thor.class_eval do + include Helpers::Environment + include Helpers::Maintenance + include Helpers::Token + + desc 'maintenance:apps', + 'List Apps impacted by maintenance schedules where '\ + 'restarts are required' + option :environment + define_method 'maintenance:apps' do + found_maintenance = false + m = maintenance_apps + Formatter.render(Renderer.current) do |root| + root.grouped_keyed_list( + { 'environment' => 'handle' }, + 'label' + ) do |node| + scoped_environments(options).each do |account| + m.select { |app| app.account.id == account.id } + .each do |app| + next unless app.maintenance_deadline + found_maintenance = true + node.object do |n| + ResourceFormatter.inject_maintenance( + n, + 'aptible restart --app', + app, + account + ) + end + end + end + end + end + if found_maintenance + explanation 'app' + else + no_maintenances 'app' + end + end + desc 'maintenance:dbs', + 'List Databases impacted by maintenance schedules where '\ + 'restarts are required' + option :environment + define_method 'maintenance:dbs' do + found_maintenance = false + m = maintenance_databases + Formatter.render(Renderer.current) do |root| + root.grouped_keyed_list( + { 'environment' => 'handle' }, + 'label' + ) do |node| + scoped_environments(options).each do |account| + m.select { |db| db.account.id == account.id } + .each do |db| + next unless db.maintenance_deadline + found_maintenance = true + node.object do |n| + ResourceFormatter.inject_maintenance( + n, + 'aptible db:restart', + db, + account + ) + end + end + end + end + end + if found_maintenance + explanation 'database' + else + no_maintenances 'database' + end + end + end + end + + def explanation(resource_type) + CLI.logger.warn "\nYou may restart these #{resource_type}(s)"\ + ' at any time, or Aptible will restart it'\ + ' during the defined window.' + end + + def no_maintenances(resource_type) + CLI.logger.info "\nNo #{resource_type}s found affected "\ + 'by maintenance schedules.' + end + end + end + end +end diff --git a/spec/aptible/cli/subcommands/maintenance_spec.rb b/spec/aptible/cli/subcommands/maintenance_spec.rb new file mode 100644 index 00000000..8400e8b9 --- /dev/null +++ b/spec/aptible/cli/subcommands/maintenance_spec.rb @@ -0,0 +1,223 @@ +require 'spec_helper' + +describe Aptible::CLI::Agent do + include Aptible::CLI::Helpers::DateHelpers + + let(:token) { double('token') } + + before do + allow(subject).to receive(:ask) + allow(subject).to receive(:save_token) + allow(subject).to receive(:fetch_token) { token } + end + + let(:handle) { 'foobar' } + let(:stack) { Fabricate(:stack, internal_domain: 'aptible.in') } + let(:account) { Fabricate(:account, stack: stack) } + let(:database) { Fabricate(:database, handle: handle, account: account) } + let(:staging) { Fabricate(:account, handle: 'staging') } + let(:prod) { Fabricate(:account, handle: 'production') } + let(:maintenance_dbs) do + [ + [staging, 'staging-redis-db', [Time.now + 1.minute, Time.now + 2.minute]], + [staging, 'staging-postgres-db', nil], + [prod, 'prod-elsearch-db', [Time.now + 1.minute, Time.now + 2.minute]], + [prod, 'prod-postgres-db', nil] + ].map do |a, h, m| + Fabricate( + :maintenance_database, + account: a, + handle: h, + maintenance_deadline: m + ) + end + end + let(:maintenance_apps) do + [ + [staging, 'staging-app-1', [Time.now + 1.minute, Time.now + 2.minute]], + [staging, 'staging-app-2', nil], + [prod, 'prod-app-1', [Time.now + 1.minute, Time.now + 2.minute]], + [prod, 'prod-app-2', nil] + ].map do |a, h, m| + Fabricate( + :maintenance_app, + account: a, + handle: h, + maintenance_deadline: m + ) + end + end + + describe '#maintenance:dbs' do + before do + token = 'the-token' + allow(subject).to receive(:fetch_token) { token } + allow(Aptible::Api::Account).to receive(:all) + .with(token: token) + .and_return([staging, prod]) + allow(Aptible::Api::MaintenanceDatabase).to receive(:all) + .with(token: token) + .and_return(maintenance_dbs) + end + + context 'when no account is specified' do + it 'prints out the grouped database handles for all accounts' do + subject.send('maintenance:dbs') + + expect(captured_output_text).to include('=== staging') + expect(captured_output_text).to include('staging-redis-db') + a_start_date_as_string = utc_string( + maintenance_dbs[0].maintenance_deadline[0].to_s + ).to_s + a_end_date_as_string = utc_string( + maintenance_dbs[0].maintenance_deadline[1].to_s + ).to_s + expect(captured_output_text).to include(a_start_date_as_string) + expect(captured_output_text).to include(a_end_date_as_string) + expect(captured_output_text).not_to include('staging-postgres-db') + + expect(captured_output_text).to include('=== production') + expect(captured_output_text).to include('prod-elsearch-db') + b_start_date_as_string = utc_string( + maintenance_dbs[2].maintenance_deadline[0].to_s + ).to_s + b_end_date_as_string = utc_string( + maintenance_dbs[2].maintenance_deadline[1].to_s + ).to_s + expect(captured_output_text).to include(b_start_date_as_string) + expect(captured_output_text).to include(b_end_date_as_string) + expect(captured_output_text).not_to include('prod-postgres-db') + + expect(captured_output_json.to_s) + .to include( + 'aptible db:restart staging-redis-db --environment staging' + ) + expect(captured_output_json.to_s) + .to include( + 'aptible db:restart prod-elsearch-db --environment production' + ) + end + end + + context 'when a valid account is specified' do + it 'prints out the database handles for the account' do + subject.options = { environment: 'staging' } + subject.send('maintenance:dbs') + + expect(captured_output_text).to include('=== staging') + expect(captured_output_text).to include('staging-redis-db') + a_start_date_as_string = utc_string( + maintenance_dbs[0].maintenance_deadline[0].to_s + ).to_s + a_end_date_as_string = utc_string( + maintenance_dbs[0].maintenance_deadline[1].to_s + ).to_s + expect(captured_output_text).to include(a_start_date_as_string) + expect(captured_output_text).to include(a_end_date_as_string) + expect(captured_output_text).not_to include('staging-postgres-db') + + expect(captured_output_text).not_to include('=== production') + expect(captured_output_text).not_to include('prod-elsearch-db') + expect(captured_output_text).not_to include('prod-postgres-db') + + expect(captured_output_json.to_s) + .to include( + 'aptible db:restart staging-redis-db --environment staging' + ) + end + end + + context 'when an invalid account is specified' do + it 'prints out an error' do + subject.options = { environment: 'foo' } + expect { subject.send('maintenance:dbs') } + .to raise_error('Specified account does not exist') + end + end + end + describe '#maintenance:apps' do + before do + token = 'the-token' + allow(subject).to receive(:fetch_token) { token } + allow(Aptible::Api::Account).to receive(:all).with(token: token) + .and_return([staging, prod]) + allow(Aptible::Api::MaintenanceApp).to receive(:all).with(token: token) + .and_return(maintenance_apps) + end + + context 'when no account is specified' do + it 'prints out the grouped app handles for all accounts' do + subject.send('maintenance:apps') + + expect(captured_output_text).to include('=== staging') + expect(captured_output_text).to include('staging-app-1') + a_start_date_as_string = utc_string( + maintenance_apps[0].maintenance_deadline[0].to_s + ).to_s + a_end_date_as_string = utc_string( + maintenance_apps[0].maintenance_deadline[1].to_s + ).to_s + expect(captured_output_text).to include(a_start_date_as_string) + expect(captured_output_text).to include(a_end_date_as_string) + expect(captured_output_text).not_to include('staging-app-2') + + expect(captured_output_text).to include('=== production') + expect(captured_output_text).to include('prod-app-1') + b_start_date_as_string = utc_string( + maintenance_apps[2].maintenance_deadline[0].to_s + ).to_s + b_end_date_as_string = utc_string( + maintenance_apps[2].maintenance_deadline[1].to_s + ).to_s + expect(captured_output_text).to include(b_start_date_as_string) + expect(captured_output_text).to include(b_end_date_as_string) + expect(captured_output_text).not_to include('prod-app-2') + + expect(captured_output_json.to_s) + .to include( + 'aptible restart --app staging-app-1 --environment staging' + ) + expect(captured_output_json.to_s) + .to include( + 'aptible restart --app prod-app-1 --environment production' + ) + end + end + + context 'when a valid account is specified' do + it 'prints out the app handles for the account' do + subject.options = { environment: 'staging' } + subject.send('maintenance:apps') + + expect(captured_output_text).to include('=== staging') + expect(captured_output_text).to include('staging-app-1') + a_start_date_as_string = utc_string( + maintenance_apps[0].maintenance_deadline[0].to_s + ).to_s + a_end_date_as_string = utc_string( + maintenance_apps[0].maintenance_deadline[1].to_s + ).to_s + expect(captured_output_text).to include(a_start_date_as_string) + expect(captured_output_text).to include(a_end_date_as_string) + expect(captured_output_text).not_to include('staging-app-2') + + expect(captured_output_text).not_to include('=== production') + expect(captured_output_text).not_to include('prod-app-1') + expect(captured_output_text).not_to include('prod-app-2') + + expect(captured_output_json.to_s) + .to include( + 'aptible restart --app staging-app-1 --environment staging' + ) + end + end + + context 'when an invalid account is specified' do + it 'prints out an error' do + subject.options = { environment: 'foo' } + expect { subject.send('maintenance:apps') } + .to raise_error('Specified account does not exist') + end + end + end +end diff --git a/spec/fabricators/maintenance_app_fabricator.rb b/spec/fabricators/maintenance_app_fabricator.rb new file mode 100644 index 00000000..b07ce09e --- /dev/null +++ b/spec/fabricators/maintenance_app_fabricator.rb @@ -0,0 +1,10 @@ +class StubMaintenanceApp < OpenStruct; end + +Fabricator(:maintenance_app, from: :stub_maintenance_app) do + id { Fabricate.sequence(:app_id) { |i| i } } + handle 'hello' + status 'provisioned' + account + created_at { Time.now } + maintenance_deadline { [Time.now + 1.minute, Time.now + 2.minute] } +end diff --git a/spec/fabricators/maintenance_database_fabricator.rb b/spec/fabricators/maintenance_database_fabricator.rb new file mode 100644 index 00000000..41f48e90 --- /dev/null +++ b/spec/fabricators/maintenance_database_fabricator.rb @@ -0,0 +1,10 @@ +class StubMaintenanceDatabase < OpenStruct; end + +Fabricator(:maintenance_database, from: :stub_maintenance_database) do + id { Fabricate.sequence(:database_id) { |i| i } } + handle 'hello' + status 'provisioned' + account + created_at { Time.now } + maintenance_deadline { [Time.now + 1.minute, Time.now + 2.minute] } +end