Skip to content

Commit

Permalink
Add backup plugin (#103)
Browse files Browse the repository at this point in the history
Add Backup plugin
  • Loading branch information
borsothy authored and askreet committed Jun 15, 2016
1 parent 72774c1 commit be9bfaf
Show file tree
Hide file tree
Showing 5 changed files with 305 additions and 0 deletions.
60 changes: 60 additions & 0 deletions docs/plugins/backup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Moonshot backup plugin

Moonshot plugin for backing up config files.

## Functionality

The plugin collects and deflates certain files to a single tarball,
and uploads that to a a given S3 bucket. The whole process happens
in memory, nothing is written to disk. The plugin currently supports single files only,
including whole directories in your tarball is not possible yet.

The plugin uses the Moonshot AWS config, meaning that the bucket must be
present in the same account and region as your deployment.

## Basic usage

When instantiating a class, you need to set the following options
in a block, where the object is provided as a block argument:

- `bucket`: the name of the S3 bucket you wish to upload the tarball
- `files`: an array of relative path names as strings
- `hooks`: which hooks to run the backup logic, works with all valid Moonshot hooks
- `target_name`: tarball archive name, default: `<app_name>_<timestamp>_<user>.tar.gz`

## Default method

If you wish to back up only the current template and parameter files, you can simply
use the factory method provided:

```ruby
plugin(Moonshot::Plugins::Backup.to_bucket('your-bucket-name'))
```

## Placeholders

You can use the following placeholders both in your filenames
and tarball target names (meanings are pretty self explaining):

- `%{app_name}`
- `%{stack_name}`
- `%{timestamp}`
- `%{user}`

## Example

A possible use-case is backing up a CF template and/or
parameter file after create or update.

```ruby
plugin(
Backup.new do |b|
b.bucket = 'your-bucket-name'
b.files = [
'cloud_formation/%{app_name}.json',
'cloud_formation/parameters/%{stack_name}.yml'
]
b.hooks = [:post_create, :post_update]
end
)
```
7 changes: 7 additions & 0 deletions lib/moonshot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ module BuildMechanism # rubocop:disable Documentation
end
module DeploymentMechanism # rubocop:disable Documentation
end
module Plugins # rubocop:disable Documentation
end
end

[
Expand Down Expand Up @@ -39,3 +41,8 @@ module DeploymentMechanism # rubocop:disable Documentation
'build_mechanism/version_proxy',
'deployment_mechanism/code_deploy'
].each { |f| require_relative "moonshot/#{f}" }

# Bundled plugins
[
'backup'
].each { |p| require_relative "plugins/#{p}" }
145 changes: 145 additions & 0 deletions lib/plugins/backup.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
require 'rubygems/package'
require 'zlib'
require_relative '../moonshot/creds_helper'

module Moonshot
module Plugins
# Moonshot plugin class for deflating and uploading files on given hooks
class Backup
include Moonshot::CredsHelper

attr_accessor :bucket,
:files,
:hooks,
:target_name

def initialize
yield self if block_given?
raise ArgumentError if @bucket.nil? || @files.nil? || @files.empty? || @hooks.nil?
@target_name ||= '%{app_name}_%{timestamp}_%{user}.tar.gz'
end

# Factory method to create preconfigured Backup plugins. Uploads current
# template and parameter files.
# @param backup [String] target bucket name
# @return [Backup] configured backup object
def self.to_bucket(bucket)
raise ArgumentError if bucket.nil? || bucket.empty?
Moonshot::Plugins::Backup.new do |b|
b.bucket = bucket
b.files = [
'cloud_formation/%{app_name}.json',
'cloud_formation/parameters/%{stack_name}.yml'
]
b.hooks = [:post_create, :post_update]
end
end

# Main worker method, creates a tarball of the given files, and uploads
# to an S3 bucket.
#
# @param resources [Resources] injected Moonshot resources
def backup(resources) # rubocop:disable Metrics/AbcSize
raise ArgumentError if resources.nil?

@app_name = resources.stack.app_name
@stack_name = resources.stack.name
@target_name = render(@target_name)

resources.ilog.start("#{log_message} in progress.") do |s|
begin
tar_out = tar(@files)
zip_out = zip(tar_out)
upload(zip_out)

s.success("#{log_message} succeeded.")
rescue StandardError => e
s.failure("#{log_message} failed: #{e}")
ensure
tar_out.close unless tar_out.nil?
zip_out.close unless zip_out.nil?
end
end
end

# Dynamically responding to hooks supplied in the constructor
def method_missing(method_name, *args, &block)
@hooks.include?(method_name) ? backup(*args) : super
end

def respond_to?(method_name, include_private = false)
@hooks.include?(method_name) || super
end

private

attr_accessor :app_name,
:stack_name

# Create a tar archive in memory, returning the IO object pointing at the
# beginning of the archive.
#
# @param target_files [Array<String>]
# @return tar_stream [IO]
def tar(target_files)
tar_stream = StringIO.new
Gem::Package::TarWriter.new(tar_stream) do |writer|
target_files.each do |file|
file = render(file)

writer.add_file(File.basename(file), 0644) do |io|
File.open(file, 'r') { |f| io.write(f.read) }
end
end
end
tar_stream.seek(0)
tar_stream
end

# Create a zip archive in memory, returning the IO object pointing at the
# beginning of the zipfile.
#
# @param io_tar [IO] tar stream
# @return zip_stream [IO] IO stream of zipped file
def zip(io_tar)
zip_stream = StringIO.new
Zlib::GzipWriter.wrap(zip_stream) do |gz|
gz.write(io_tar.read)
gz.finish
end
zip_stream.seek(0)
zip_stream
end

# Uploads an object from the passed IO stream to the specified bucket
#
# @param io_zip [IO] tar stream
def upload(io_zip)
s3_client.put_object(
acl: 'private',
bucket: @bucket,
key: @target_name,
body: io_zip
)
end

# Renders string with the specified placeholders
#
# @param io_zip [String] raw string with placeholders
# @return [String] rendered string
def render(placeholder)
format(
placeholder,
app_name: @app_name,
stack_name: @stack_name,
timestamp: Time.now.to_i.to_s,
user: ENV['USER']
)
end

def log_message
"Uploading '#{@target_name}' to '#{@bucket}'"
end
end
end
end
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ pages:
- 'Build': 'mechanisms/build.md'
- 'Deployment': 'mechanisms/deployment.md'
- Plugins: plugins.md
- Bundled plugins:
- 'Backup plugin': 'plugins/backup.md'
- About:
- 'How to Contribute': 'about/contribute.md'
- 'License': 'about/license.md'
91 changes: 91 additions & 0 deletions spec/moonshot/plugins/backup_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
describe Moonshot::Plugins::Backup do
let(:hooks) do
[
:pre_create,
:post_create,
:pre_update,
:post_update,
:pre_delete,
:post_delete,
:pre_status,
:post_status,
:pre_doctor,
:post_doctor
]
end

describe '#new' do
subject { Moonshot::Plugins::Backup }

it 'should yield self' do
backup = subject.new do |b|
b.bucket = 'test'
b.files = %w(sample files)
b.hooks = [:sample, :hooks]
end
expect(backup.bucket).to eq('test')
expect(backup.files).to eq(%w(sample files))
expect(backup.hooks).to eq([:sample, :hooks])
end

it 'should raise ArgumentError if insufficient parameters are provided' do
expect { subject.new }.to raise_error(ArgumentError)
end

let(:backup) do
subject.new do |b|
b.bucket = 'testbucket'
b.files = %w(test files)
b.hooks = [:post_create, :post_update]
end
end
it 'should set a default value to target_name if not specified' do
expect(backup.target_name).to eq '%{app_name}_%{timestamp}_%{user}.tar.gz'
end
end

describe '#to_backup' do
let(:test_bucket_name) { 'test_bucket' }
let(:registered_hooks) { [:post_create, :post_update] }
let(:unregistered_hooks) { hooks - registered_hooks }

subject { Moonshot::Plugins::Backup.to_bucket(test_bucket_name) }

it 'should return a Backup object' do
expect(subject).to be_a Moonshot::Plugins::Backup
end

it 'should raise ArgumentError when no bucket specified' do
end

it 'should set default config values' do
expect(subject.bucket).to eq test_bucket_name
expect(subject.files).to eq [
'cloud_formation/%{app_name}.json',
'cloud_formation/parameters/%{stack_name}.yml'
]
expect(subject.hooks).to eq [:post_create, :post_update]
end

it 'should only respond to the default hooks' do
expect(subject).to respond_to(*registered_hooks)
expect(subject).not_to respond_to(*unregistered_hooks)
end
end

describe '#backup' do
subject { Moonshot::Plugins::Backup.to_bucket bucket: 'bucket' }

let(:resources) do
instance_double(
Moonshot::Resources,
stack: instance_double(Moonshot::Stack),
ilog: instance_double(Moonshot::InteractiveLoggerProxy)
)
end

it 'should raise ArgumentError if resources not injected' do
expect { subject.backup }.to raise_error(ArgumentError)
end
end
end

0 comments on commit be9bfaf

Please sign in to comment.