moonshot 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: cdc444c7d1abfc032353018913e6a8eccb90b035
4
+ data.tar.gz: 82a6a2f524bc812d9c0c7155b1437a10c378594d
5
+ SHA512:
6
+ metadata.gz: 870266482ea59c020549ec66f4cd114b4cdb2dff89957a18387ff3eb6409b5f7f1b831c06c0503397ed79cc597bfdc659b8d9100bfeb9d2df3f177aad5953538
7
+ data.tar.gz: efbef2587e5b0305c608bcdd21695388145a98f981097eb5be875dcb27faa1cbeac1e354631891b3fd8ced6b3d898d7c3d59050199ea21c006decb65e8072c84
@@ -0,0 +1,60 @@
1
+ # The S3Bucket stores builds in an S3 Bucket.
2
+ #
3
+ # For example:
4
+ #
5
+ # def MyApplication < Moonshot::CLI
6
+ # self.artifact_repository = S3Bucket.new('my-application-builds')
7
+ # end
8
+ class Moonshot::ArtifactRepository::S3Bucket
9
+ include Moonshot::ResourcesHelper
10
+ include Moonshot::CredsHelper
11
+ include Moonshot::DoctorHelper
12
+
13
+ attr_reader :bucket_name
14
+
15
+ def initialize(bucket_name)
16
+ @bucket_name = bucket_name
17
+ end
18
+
19
+ def store_hook(build_mechanism, version_name)
20
+ unless build_mechanism.respond_to?(:output_file)
21
+ raise "S3Bucket does not know how to store artifacts from #{build_mechanism.class}, no method '#output_file'." # rubocop:disable LineLength
22
+ end
23
+
24
+ file = build_mechanism.output_file
25
+ bucket_name = @bucket_name
26
+ key = filename_for_version(version_name)
27
+
28
+ ilog.start_threaded "Uploading #{file} to s3://#{bucket_name}/#{key}" do |s|
29
+ s3_client.put_object(key: key, body: File.open(file), bucket: bucket_name)
30
+ s.success "Uploaded s3://#{bucket_name}/#{key} successfully."
31
+ end
32
+ end
33
+
34
+ def filename_for_version(version_name)
35
+ "#{version_name}.tar.gz"
36
+ end
37
+
38
+ private
39
+
40
+ def doctor_check_bucket_exists
41
+ s3_client.get_bucket_location(bucket: @bucket_name)
42
+ success "Bucket '#{@bucket_name}' exists."
43
+ rescue => e
44
+ # This is warning because the role you use for deployment may not actually
45
+ # be able to read builds, however the instance role assigned to the nodes
46
+ # might.
47
+ str = "Could not get information about bucket '#{@bucket_name}'."
48
+ warning(str, e.message)
49
+ end
50
+
51
+ def doctor_check_bucket_writable
52
+ s3_client.put_object(key: 'test-object', body: '', bucket: @bucket_name)
53
+ s3_client.delete_object(key: 'test-object', bucket: @bucket_name)
54
+ success 'Bucket is writable, new builds can be uploaded.'
55
+ rescue => e
56
+ # This is a warning because you may deploy to an environment where you have
57
+ # read access to builds, but could not publish a new build.
58
+ warning('Could not write to bucket, you may still be able to deploy existing builds.', e.message) # rubocop:disable LineLength
59
+ end
60
+ end
@@ -0,0 +1,89 @@
1
+ require 'moonshot/artifact_repository/s3_bucket'
2
+ require 'moonshot/shell'
3
+ require 'securerandom'
4
+ require 'semantic'
5
+ require 'tmpdir'
6
+
7
+ module Moonshot::ArtifactRepository
8
+ # S3 Bucket repository backed by GitHub releases.
9
+ # If a SemVer package isn't found in S3, it is copied from GitHub releases.
10
+ class S3BucketViaGithubReleases < S3Bucket
11
+ include Moonshot::BuildMechanism
12
+ include Moonshot::Shell
13
+
14
+ # @override
15
+ # If release version, transfer from GitHub to S3.
16
+ def store_hook(build_mechanism, version)
17
+ if release?(version)
18
+ if (@output_file = build_mechanism.output_file)
19
+ attach_release_asset(version, @output_file)
20
+ # Upload to s3.
21
+ super
22
+ else
23
+ # If there is no output file, assume it's on GitHub already.
24
+ transfer_release_asset_to_s3(version)
25
+ end
26
+ else
27
+ super
28
+ end
29
+ end
30
+
31
+ # @override
32
+ # If release version, transfer from GitHub to S3.
33
+ # @todo This is a super hacky place to handle the transfer, give
34
+ # artifact repositories a hook before deploy.
35
+ def filename_for_version(version)
36
+ s3_name = super
37
+ if !@output_file && release?(version) && !in_s3?(s3_name)
38
+ github_to_s3(version, s3_name)
39
+ end
40
+ s3_name
41
+ end
42
+
43
+ private
44
+
45
+ def release?(version)
46
+ ::Semantic::Version.new(version)
47
+ rescue ArgumentError
48
+ false
49
+ end
50
+
51
+ def in_s3?(key)
52
+ s3_client.head_object(key: key, bucket: bucket_name)
53
+ rescue ::Aws::S3::Errors::NotFound
54
+ false
55
+ end
56
+
57
+ def attach_release_asset(version, file)
58
+ # -m '' leaves message unchanged.
59
+ cmd = "hub release edit #{version} -m '' --attach=#{file}"
60
+ sh_step(cmd)
61
+ end
62
+
63
+ def transfer_release_asset_to_s3(version)
64
+ ilog.start_threaded "Transferring #{version} to S3" do |s|
65
+ key = filename_for_version(version)
66
+ s.success "Uploaded s3://#{bucket_name}/#{key} successfully."
67
+ end
68
+ end
69
+
70
+ def github_to_s3(version, s3_name)
71
+ Dir.mktmpdir('github_to_s3', Dir.getwd) do |tmpdir|
72
+ Dir.chdir(tmpdir) do
73
+ sh_out("hub release download #{version}")
74
+ file = File.open(Dir.glob("*#{version}*.tar.gz").fetch(0))
75
+ s3_client.put_object(key: s3_name, body: file, bucket: bucket_name)
76
+ end
77
+ end
78
+ end
79
+
80
+ def doctor_check_hub_release_download
81
+ sh_out('hub release download --help')
82
+ rescue
83
+ critical '`hub release download` command missing, upgrade hub.' \
84
+ ' See https://github.com/github/hub/pull/1103'
85
+ else
86
+ success '`hub release download` command available.'
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,148 @@
1
+ require 'forwardable'
2
+ require 'moonshot/shell'
3
+ require 'open3'
4
+ require 'semantic'
5
+ require 'shellwords'
6
+ require 'tempfile'
7
+ require 'vandamme'
8
+
9
+ module Moonshot::BuildMechanism
10
+ # A build mechanism that creates a tag and GitHub release.
11
+ class GithubRelease # rubocop:disable Metrics/ClassLength
12
+ extend Forwardable
13
+ include Moonshot::ResourcesHelper
14
+ include Moonshot::DoctorHelper
15
+ include Moonshot::Shell
16
+
17
+ def_delegator :@build_mechanism, :output_file
18
+
19
+ # @param build_mechanism Delegates building after GitHub release is created.
20
+ def initialize(build_mechanism)
21
+ @build_mechanism = build_mechanism
22
+ end
23
+
24
+ def doctor_hook
25
+ super
26
+ @build_mechanism.doctor_hook
27
+ end
28
+
29
+ def resources=(r)
30
+ super
31
+ @build_mechanism.resources = r
32
+ end
33
+
34
+ def pre_build_hook(version)
35
+ @semver = ::Semantic::Version.new(version)
36
+ @target_version = [@semver.major, @semver.minor, @semver.patch].join('.')
37
+ sh_step('git fetch --tags upstream')
38
+ @sha = `git rev-parse HEAD`.chomp
39
+ validate_commit
40
+ @changes = validate_changelog(@target_version)
41
+ confirm_or_fail(@semver)
42
+ @build_mechanism.pre_build_hook(version)
43
+ end
44
+
45
+ def build_hook(version)
46
+ assert_state(version)
47
+ git_tag(version, @sha, @changes)
48
+ git_push_tag('upstream', version)
49
+ hub_create_release(@semver, @sha, @changes)
50
+ ilog.msg("#{releases_url}/tag/#{version}")
51
+ @build_mechanism.build_hook(version)
52
+ end
53
+
54
+ def post_build_hook(version)
55
+ assert_state(version)
56
+ @build_mechanism.post_build_hook(version)
57
+ end
58
+
59
+ private
60
+
61
+ # We carry state between hooks, make sure that's still valid.
62
+ def assert_state(version)
63
+ raise "#{version} != #{@semver}" unless version == @semver.to_s
64
+ end
65
+
66
+ def confirm_or_fail(version)
67
+ say("\nCommit Summary", :yellow)
68
+ say("#{@commit_detail}\n")
69
+ say('Commit CI Status', :yellow)
70
+ say("#{@ci_statuses}\n")
71
+ say("Changelog for #{version}", :yellow)
72
+ say("#{@changes}\n\n")
73
+
74
+ q = "Do you wan't to tag and release this commit as #{version}? [y/n]"
75
+ raise Thor::Error, 'Release declined.' unless yes?(q)
76
+ end
77
+
78
+ def git_tag(tag, sha, annotation)
79
+ cmd = "git tag -a #{tag} #{sha} --file=-"
80
+ sh_step(cmd, stdin: annotation)
81
+ end
82
+
83
+ def git_push_tag(remote, tag)
84
+ cmd = "git push #{remote} refs/tags/#{tag}:refs/tags/#{tag}"
85
+ sh_step(cmd) do
86
+ sleep 2 # GitHub needs a moment to register the tag.
87
+ end
88
+ end
89
+
90
+ def hub_create_release(semver, commitish, changelog_entry)
91
+ message = "#{semver}\n\n#{changelog_entry}"
92
+ cmd = "hub release create #{semver} --commitish=#{commitish}"
93
+ cmd << ' --prerelease' if semver.pre || semver.build
94
+ cmd << " --message=#{Shellwords.escape(message)}"
95
+ sh_step(cmd)
96
+ end
97
+
98
+ def validate_commit
99
+ cmd = "git show --stat #{@sha}"
100
+ sh_step(cmd, msg: "Validate commit #{@sha}.") do |_, out|
101
+ @commit_detail = out
102
+ end
103
+ cmd = "hub ci-status --verbose #{@sha}"
104
+ sh_step(cmd, msg: "Check CI status for #{@sha}.") do |_, out|
105
+ @ci_statuses = out
106
+ end
107
+ end
108
+
109
+ def validate_changelog(version)
110
+ changes = nil
111
+ ilog.start_threaded('Validate `CHANGELOG.md`.') do |step|
112
+ changes = fetch_changes(version)
113
+ step.success
114
+ end
115
+ changes
116
+ end
117
+
118
+ def fetch_changes(version)
119
+ parser = Vandamme::Parser.new(
120
+ changelog: File.read('CHANGELOG.md'),
121
+ format: 'markdown'
122
+ )
123
+ parser.parse.fetch(version) do
124
+ raise "#{version} not found in CHANGELOG.md"
125
+ end
126
+ end
127
+
128
+ def releases_url
129
+ `hub browse -u -- releases`.chomp
130
+ end
131
+
132
+ def doctor_check_upstream
133
+ sh_out('git remote | grep ^upstream$')
134
+ rescue => e
135
+ critical "git remote `upstream` not found.\n#{e.message}"
136
+ else
137
+ success 'git remote `upstream` exists.'
138
+ end
139
+
140
+ def doctor_check_hub_auth
141
+ sh_out('hub ci-status 0.0.0')
142
+ rescue => e
143
+ critical "`hub` failed, install hub and authorize it.\n#{e.message}"
144
+ else
145
+ success '`hub` installed and authorized.'
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,84 @@
1
+ require 'open3'
2
+ include Open3
3
+
4
+ # Compile a release artifact using a shell script.
5
+ #
6
+ # The output file will be deleted before the script is run, and is expected to
7
+ # exist after the script exits. Any non-zero exit status will be consider a
8
+ # build failure, and any output will be displayed to the user.
9
+ #
10
+ # Creating a new Script BuildMechanism looks like this:
11
+ #
12
+ # class MyReleaseTool < Moonshot::CLI
13
+ # include Moonshot::BuildMechanism
14
+ # self.build_mechanism = Script.new('script/build.sh')
15
+ # end
16
+ #
17
+ class Moonshot::BuildMechanism::Script
18
+ include Moonshot::ResourcesHelper
19
+ include Moonshot::DoctorHelper
20
+
21
+ attr_reader :output_file
22
+
23
+ def initialize(script, output_file: 'output.tar.gz')
24
+ @script = script
25
+ @output_file = output_file
26
+ end
27
+
28
+ def pre_build_hook(_version)
29
+ File.delete(@output_file) if File.exist?(@output_file)
30
+ end
31
+
32
+ def build_hook(version)
33
+ env = {
34
+ 'VERSION' => version,
35
+ 'OUTPUT_FILE' => @output_file
36
+ }
37
+ ilog.start_threaded "Running Script: #{@script}" do |s|
38
+ run_script(s, env: env)
39
+ end
40
+ end
41
+
42
+ def post_build_hook(_version)
43
+ unless File.exist?(@output_file) # rubocop:disable GuardClause
44
+ raise Thor::Error, 'Build command did not produce output file!'
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def run_script(step, env: {}) # rubocop:disable AbcSize
51
+ popen2e(env, @script) do |_, out, wait|
52
+ output = []
53
+
54
+ loop do
55
+ str = out.gets
56
+ unless str.nil?
57
+ output << str.chomp
58
+ ilog.debug(str.chomp)
59
+ end
60
+ break if out.eof?
61
+ end
62
+
63
+ result = wait.value
64
+ if result.exitstatus == 0
65
+ step.success "Build script #{@script} exited successfully!"
66
+ end
67
+ unless result.exitstatus == 0
68
+ ilog.error "Build script failed with exit status #{result.exitstatus}!"
69
+ ilog.error 'Last 10 lines of output follows:'
70
+ output.pop(10).each { |l| ilog.error l }
71
+
72
+ step.failure "Build script #{@script} failed with exit status #{result.exitstatus}!"
73
+ end
74
+ end
75
+ end
76
+
77
+ def doctor_check_script_exists
78
+ if File.exist?(@script)
79
+ success "Script '#{@script}' exists."
80
+ else
81
+ critical "Could not find build script '#{@script}'!"
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,70 @@
1
+ require 'moonshot/shell'
2
+
3
+ module Moonshot::BuildMechanism
4
+ # This simply waits for Travis-CI to finish building a job matching the
5
+ # version and 'BUILD=1'.
6
+ class TravisDeploy
7
+ include Moonshot::ResourcesHelper
8
+ include Moonshot::DoctorHelper
9
+ include Moonshot::Shell
10
+
11
+ attr_reader :output_file
12
+
13
+ def initialize(slug, pro: false)
14
+ @slug = slug
15
+ @endpoint = pro ? '--pro' : '--org'
16
+ @cli_args = "-r #{@slug} #{@endpoint}"
17
+ end
18
+
19
+ def pre_build_hook(_)
20
+ end
21
+
22
+ def build_hook(version)
23
+ job_number = find_build_and_job(version)
24
+ wait_for_job(job_number)
25
+ check_build(version)
26
+ end
27
+
28
+ def post_build_hook(_)
29
+ end
30
+
31
+ private
32
+
33
+ def find_build_and_job(version)
34
+ job_number = nil
35
+ ilog.start_threaded('Find Travis CI build') do |step|
36
+ sleep 2
37
+ build_out = sh_out("bundle exec travis show #{@cli_args} #{version}")
38
+ unless (job_number = build_out.match(/^#(\d+\.\d+) .+BUILD=1.+/)[1])
39
+ raise "Build for #{version} not found.\n#{build_out}"
40
+ end
41
+ step.success("Travis CI ##{job_number.gsub(/\..*/, '')} running.")
42
+ end
43
+ job_number
44
+ end
45
+
46
+ def wait_for_job(job_number)
47
+ cmd = "bundle exec travis logs #{@cli_args} #{job_number}"
48
+ # This log tailing fails at the end of the file. travis bug.
49
+ sh_step(cmd, fail: false)
50
+ end
51
+
52
+ def check_build(version)
53
+ cmd = "bundle exec travis show #{@cli_args} #{version}"
54
+ sh_step(cmd) do |step, out|
55
+ raise "Build didn't pass.\n#{build_out}" \
56
+ if out =~ /^#(\d+\.\d+) (?!passed).+BUILD=1.+/
57
+
58
+ step.success("Travis CI build for #{version} passed.")
59
+ end
60
+ end
61
+
62
+ def doctor_check_travis_auth
63
+ sh_out("bundle exec travis raw #{@endpoint} repos/#{@slug}")
64
+ rescue => e
65
+ critical "`travis` not available or not authorized.\n#{e.message}"
66
+ else
67
+ success '`travis` installed and authorized.'
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,55 @@
1
+ require 'forwardable'
2
+ require 'semantic'
3
+
4
+ # This proxies build request do different mechanisms. One for semver compliant
5
+ # releases and another for everything else.
6
+ class Moonshot::BuildMechanism::VersionProxy
7
+ extend Forwardable
8
+ include Moonshot::ResourcesHelper
9
+
10
+ def_delegator :@active, :output_file
11
+
12
+ def initialize(release:, dev:)
13
+ @release = release
14
+ @dev = dev
15
+ end
16
+
17
+ def doctor_hook
18
+ @release.doctor_hook
19
+ @dev.doctor_hook
20
+ end
21
+
22
+ def resources=(r)
23
+ super
24
+ @release.resources = r
25
+ @dev.resources = r
26
+ end
27
+
28
+ def pre_build_hook(version)
29
+ active(version).pre_build_hook(version)
30
+ end
31
+
32
+ def build_hook(version)
33
+ active(version).build_hook(version)
34
+ end
35
+
36
+ def post_build_hook(version)
37
+ active(version).post_build_hook(version)
38
+ end
39
+
40
+ private
41
+
42
+ def active(version)
43
+ @active = if release?(version)
44
+ @release
45
+ else
46
+ @dev
47
+ end
48
+ end
49
+
50
+ def release?(version)
51
+ ::Semantic::Version.new(version)
52
+ rescue ArgumentError
53
+ false
54
+ end
55
+ end
@@ -0,0 +1,146 @@
1
+ require 'interactive-logger'
2
+
3
+ # Base class for Moonshot-powered project tooling.
4
+ module Moonshot
5
+ # The main entry point for Moonshot, this class should be extended by
6
+ # project tooling.
7
+ class CLI < Thor # rubocop:disable ClassLength
8
+ class_option(:name, aliases: 'n', default: nil, type: :string)
9
+ class_option(:interactive_logger, type: :boolean, default: true)
10
+ class_option(:verbose, aliases: 'v', type: :boolean)
11
+
12
+ class << self
13
+ attr_accessor :application_name
14
+ attr_accessor :artifact_repository
15
+ attr_accessor :auto_prefix_stack
16
+ attr_accessor :build_mechanism
17
+ attr_accessor :deployment_mechanism
18
+ attr_accessor :default_parent_stack
19
+ attr_reader :plugins
20
+
21
+ def plugin(plugin)
22
+ @plugins ||= []
23
+ @plugins << plugin
24
+ end
25
+
26
+ def parent(value)
27
+ @default_parent_stack = value
28
+ end
29
+
30
+ def check_class_configuration
31
+ raise Thor::Error, 'No application_name is set!' unless application_name
32
+ end
33
+
34
+ def exit_on_failure?
35
+ true
36
+ end
37
+
38
+ def inherited(base)
39
+ base.include(Moonshot::ArtifactRepository)
40
+ base.include(Moonshot::BuildMechanism)
41
+ base.include(Moonshot::DeploymentMechanism)
42
+ end
43
+ end
44
+
45
+ def initialize(*args)
46
+ super
47
+ @log = Logger.new(STDOUT)
48
+ @log.formatter = proc do |s, d, _, msg|
49
+ "[#{self.class.name} #{s} #{d.strftime('%T')}] #{msg}\n"
50
+ end
51
+ @log.level = options[:verbose] ? Logger::DEBUG : Logger::INFO
52
+
53
+ EnvironmentParser.parse(@log)
54
+ self.class.check_class_configuration
55
+ end
56
+
57
+ no_tasks do
58
+ # Build a Moonshot::Controller from the CLI options.
59
+ def controller # rubocop:disable AbcSize, CyclomaticComplexity, PerceivedComplexity
60
+ Moonshot::Controller.new do |config|
61
+ config.app_name = self.class.application_name
62
+ config.artifact_repository = self.class.artifact_repository
63
+ config.auto_prefix_stack = self.class.auto_prefix_stack
64
+ config.build_mechanism = self.class.build_mechanism
65
+ config.deployment_mechanism = self.class.deployment_mechanism
66
+ config.environment_name = options[:name]
67
+ config.logger = @log
68
+
69
+ # Degrade to a more compatible logger if the terminal seems outdated,
70
+ # or at the users request.
71
+ if !$stdout.isatty || !options[:interactive_logger]
72
+ config.interactive_logger = InteractiveLoggerProxy.new(@log)
73
+ end
74
+
75
+ config.show_all_stack_events = true if options[:show_all_events]
76
+ config.plugins = self.class.plugins if self.class.plugins
77
+
78
+ if options[:parent]
79
+ config.parent_stacks << options[:parent]
80
+ elsif self.class.default_parent_stack
81
+ config.parent_stacks << self.class.default_parent_stack
82
+ end
83
+ end
84
+ rescue => e
85
+ raise Thor::Error, e.message
86
+ end
87
+ end
88
+
89
+ desc :list, 'List stacks for this application.'
90
+ def list
91
+ controller.list
92
+ end
93
+
94
+ desc :create, 'Create a new environment.'
95
+ option(
96
+ :parent,
97
+ type: :string,
98
+ aliases: '-p',
99
+ desc: "Parent stack to import parameters from. (Default: #{default_parent_stack || 'None'})")
100
+ option :deploy, default: true, type: :boolean, aliases: '-d',
101
+ desc: 'Choose if code should be deployed after stack is created'
102
+ option :show_all_events, desc: 'Show all stack events during update. (Default: errors only)'
103
+ def create
104
+ controller.create
105
+ controller.deploy_code if options[:deploy]
106
+ end
107
+
108
+ desc :update, 'Update the CloudFormation stack within an environment.'
109
+ option :show_all_events, desc: 'Show all stack events during update. (Default: errors only)'
110
+ def update
111
+ controller.update
112
+ end
113
+
114
+ desc :status, 'Get the status of an existing environment.'
115
+ def status
116
+ controller.status
117
+ end
118
+
119
+ desc 'deploy-code', 'Create a build from the working directory, and deploy it.' # rubocop:disable LineLength
120
+ def deploy_code
121
+ controller.deploy_code
122
+ end
123
+
124
+ desc 'build-version VERSION', 'Build a tarball of the software, ready for deployment.' # rubocop:disable LineLength
125
+ def build_version(version_name)
126
+ controller.build_version(version_name)
127
+ end
128
+
129
+ desc 'deploy-version VERSION_NAME', 'Deploy a versioned release to both EB environments in an environment.' # rubocop:disable LineLength
130
+ def deploy_version(version_name)
131
+ controller.deploy_version(version_name)
132
+ end
133
+
134
+ desc :delete, 'Delete an existing environment.'
135
+ option :show_all_events, desc: 'Show all stack events during update. (Default: errors only)'
136
+ def delete
137
+ controller.delete
138
+ end
139
+
140
+ desc :doctor, 'Run configuration checks against current environment.'
141
+ def doctor
142
+ success = controller.doctor
143
+ raise Thor::Error, 'One or more checks failed.' unless success
144
+ end
145
+ end
146
+ end