envirobly 0.7.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1ffc05f7ea0f1e40889fa8a817bd44f9cf9fc3ee4d7197e48f46bd17706b017
4
- data.tar.gz: 053373d7435caa5b10d301afafd3ef76777f29bffb59c108cd6ef4399d666061
3
+ metadata.gz: 9ef94e7e6db204bc73126d41dae8d3417e4ea7ac35e7cbecd5d496dcee842ee3
4
+ data.tar.gz: 32f53215e81d8e3d984cd7caf58fc0944c9636ba587e59dbd4573bfcd04f8a08
5
5
  SHA512:
6
- metadata.gz: f0d071e480b64d91210ae0eb422d29707950c3288ca7e8a85354bfbaff1e78bec44e0bb88606edf264aab94bf2478e85fd6a85fba1f920e2b028c55c457bdc3b
7
- data.tar.gz: ee38e1c77c0b66b90d4b36d3c89a217caef7f71f1e9c34d32ac4731eba898b265519e018501a5d5ebe41f644ca5e8534e04cf07b4ef5afa592595f60ddd5f453
6
+ metadata.gz: ef5d512c3dab84e7f4cc5ce45804d30cce526b61c83407895952536a45a901715aee2ceb39b71e5b8d68d8e477f20b4628f9f94df41a932f603a511e3d9fface
7
+ data.tar.gz: d5444e2eb995be568dcc57e7df33145d2b17792f212c9ed605a3c456d511f0e2b80968d4900642c3483d9eb01afb550e1b0071f212877706a8e7a9e265d87a9e
@@ -2,35 +2,86 @@ require "fileutils"
2
2
  require "pathname"
3
3
 
4
4
  class Envirobly::AccessToken
5
- def initialize(token = ENV.fetch("ENVIROBLY_ACCESS_TOKEN", nil))
6
- if token.nil? && File.exist?(access_token_path)
7
- @token = File.read(access_token_path)
5
+ include Envirobly::Colorize
6
+
7
+ attr_reader :shell
8
+
9
+ class << self
10
+ def destroy
11
+ if File.exist?(path)
12
+ FileUtils.rm path
13
+ end
14
+ end
15
+
16
+ def dir
17
+ if ENV["XDG_CONFIG_HOME"]
18
+ Pathname.new(ENV["XDG_CONFIG_HOME"]).join("envirobly")
19
+ else
20
+ Pathname.new(Dir.home).join(".envirobly")
21
+ end
22
+ end
23
+
24
+ def path
25
+ dir.join "access_token"
26
+ end
27
+ end
28
+
29
+ def initialize(token = ENV["ENVIROBLY_ACCESS_TOKEN"].presence, shell: nil)
30
+ @shell = shell
31
+
32
+ if token.blank? && File.exist?(self.class.path)
33
+ @token = File.read(self.class.path)
8
34
  else
9
35
  @token = token
10
36
  end
11
37
  end
12
38
 
13
39
  def save
14
- FileUtils.mkdir_p config_root
15
- File.write access_token_path, @token
16
- File.chmod 0600, access_token_path
17
- puts "Access token saved to #{access_token_path}"
40
+ FileUtils.mkdir_p self.class.dir
41
+ File.write self.class.path, @token
42
+ File.chmod 0600, self.class.path
18
43
  end
19
44
 
20
45
  def as_http_bearer
21
46
  "Bearer #{@token}"
22
47
  end
23
48
 
24
- private
25
- def config_root
26
- if ENV["XDG_CONFIG_HOME"]
27
- Pathname.new(ENV["XDG_CONFIG_HOME"]).join("envirobly")
28
- else
29
- Pathname.new(Dir.home).join(".envirobly")
49
+ def require!
50
+ return if @token.present?
51
+
52
+ shell.say "This action requires you to be signed in."
53
+ shell.say "Please visit https://on.envirobly.com/profile/access_tokens"
54
+ shell.say "to generate an access token and then paste it in here."
55
+ shell.say
56
+
57
+ set
58
+ end
59
+
60
+ def set
61
+ @token = nil
62
+
63
+ while @token.blank?
64
+ begin
65
+ @token = shell.ask("Access Token:", echo: false)
66
+ rescue Interrupt
67
+ shell.say
68
+ shell.say_error "Cancelled"
69
+ exit
30
70
  end
31
- end
32
71
 
33
- def access_token_path
34
- config_root.join "access_token"
72
+ api = Envirobly::Api.new(access_token: self)
73
+
74
+ # TODO: Eventually replace with custom `whoami` API that returns name, email...
75
+ if api.list_accounts.success?
76
+ save
77
+ shell.say
78
+ shell.say "Successfully signed in "
79
+ shell.say green_check
80
+ else
81
+ shell.say
82
+ shell.say_error "This token is invalid. Please try again"
83
+ @token = nil
84
+ end
35
85
  end
86
+ end
36
87
  end
data/lib/envirobly/api.rb CHANGED
@@ -4,36 +4,54 @@ require "socket"
4
4
  require "uri"
5
5
 
6
6
  class Envirobly::Api
7
- HOST = ENV["ENVIROBLY_API_HOST"] || "envirobly.com"
7
+ HOST = ENV["ENVIROBLY_API_HOST"].presence || "on.envirobly.com"
8
8
  USER_AGENT = "Envirobly CLI v#{Envirobly::VERSION}"
9
9
  CONTENT_TYPE = "application/json"
10
10
 
11
- def initialize
12
- @access_token = Envirobly::AccessToken.new
11
+ def initialize(access_token: Envirobly::AccessToken.new)
12
+ @access_token = access_token
13
13
  end
14
14
 
15
- def create_deployment(params)
16
- post_as_json(api_v1_deployments_url, params:, headers: authorization_headers).tap do |response|
17
- unless response.code.to_i == 200
18
- $stderr.puts "Deployment creation request responded with #{response.code}. Aborting."
15
+ def validate_shape(params)
16
+ post_as_json(api_v1_shape_validations_url, params:, headers: authorization_headers).tap do |response|
17
+ unless response.success?
18
+ $stderr.puts "Validation request responded with #{response.code}. Aborting."
19
19
  exit 1
20
20
  end
21
21
  end
22
22
  end
23
23
 
24
- RETRY_INTERVAL_SECONDS = 3
25
- MAX_RETRIES = 5
24
+ def create_deployment(params)
25
+ post_as_json(api_v1_deployments_url, params:, headers: authorization_headers)
26
+ end
27
+
28
+ def list_accounts
29
+ get_as_json api_v1_accounts_url, headers: authorization_headers
30
+ end
31
+
32
+ def list_regions
33
+ get_as_json api_v1_regions_url, headers: authorization_headers
34
+ end
35
+
36
+ MAX_RETRIES = 30
37
+ SHORT_RETRY_INTERVAL = 2.seconds
38
+ LONG_RETRY_INTERVAL = 6.seconds
26
39
  def get_deployment_with_delay_and_retry(url, tries = 1)
27
- sleep RETRY_INTERVAL_SECONDS * tries
40
+ sleep SHORT_RETRY_INTERVAL * tries
28
41
  response = get_as_json URI(url)
29
42
 
30
- if response.code.to_i == 200
43
+ if response.success?
31
44
  response
32
45
  elsif MAX_RETRIES <= tries
33
46
  $stderr.puts "Max retries exhausted while waiting for deployment credentials. Aborting."
34
47
  exit 1
35
48
  else
36
- sleep RETRY_INTERVAL_SECONDS * tries
49
+ if tries > 3
50
+ sleep LONG_RETRY_INTERVAL
51
+ else
52
+ sleep SHORT_RETRY_INTERVAL
53
+ end
54
+
37
55
  get_deployment_with_delay_and_retry(url, tries + 1)
38
56
  end
39
57
  end
@@ -55,8 +73,24 @@ class Envirobly::Api
55
73
  end
56
74
 
57
75
  private
76
+ def api_v1_shape_validations_url
77
+ api_url_for "v1/shape_validations"
78
+ end
79
+
58
80
  def api_v1_deployments_url
59
- URI::HTTPS.build(host: HOST, path: "/api/v1/deployments")
81
+ api_url_for "v1/deployments"
82
+ end
83
+
84
+ def api_v1_accounts_url
85
+ api_url_for "v1/accounts"
86
+ end
87
+
88
+ def api_v1_regions_url
89
+ api_url_for "v1/regions"
90
+ end
91
+
92
+ def api_url_for(path)
93
+ URI::HTTPS.build(host: HOST, path: "/api/#{path}")
60
94
  end
61
95
 
62
96
  def request(url, type:, headers: {})
@@ -74,7 +108,11 @@ class Envirobly::Api
74
108
 
75
109
  http.request(request).tap do |response|
76
110
  def response.object
77
- @json_parsed_body ||= JSON.parse body
111
+ @json_parsed_body ||= JSON.parse(body)
112
+ end
113
+
114
+ def response.success?
115
+ (200..299).include?(code.to_i)
78
116
  end
79
117
  end
80
118
  end
@@ -1,8 +1,13 @@
1
+ # @deprecated
1
2
  class Envirobly::Aws::Credentials
2
3
  def initialize(params)
3
4
  @params = params
4
5
  end
5
6
 
7
+ def to_h
8
+ @params
9
+ end
10
+
6
11
  def as_env_vars
7
12
  [
8
13
  %(AWS_ACCESS_KEY_ID="#{@params.fetch("access_key_id")}"),
@@ -0,0 +1,221 @@
1
+ require "zlib"
2
+ require "json"
3
+ require "open3"
4
+ require "concurrent"
5
+ require "aws-sdk-s3"
6
+
7
+ class Envirobly::Aws::S3
8
+ OBJECTS_PREFIX = "blobs"
9
+ MANIFESTS_PREFIX = "manifests"
10
+ CONCURRENCY = 6
11
+
12
+ def initialize(bucket:, region:, credentials: nil)
13
+ @region = region
14
+ @bucket = bucket
15
+
16
+ client_options = { region: }
17
+ unless credentials.nil?
18
+ client_options.merge! credentials.transform_keys(&:to_sym)
19
+ end
20
+
21
+ @client = Aws::S3::Client.new(client_options)
22
+ resource = Aws::S3::Resource.new(client: @client)
23
+ @bucket_resource = resource.bucket(@bucket)
24
+ end
25
+
26
+ def push(commit)
27
+ if object_exists?(manifest_key(commit.object_tree_checksum))
28
+ print "Build context is already uploaded"
29
+ $stdout.flush
30
+ return
31
+ end
32
+
33
+ # puts "Pushing #{commit.object_tree_checksum} to #{@bucket}"
34
+
35
+ manifest = []
36
+ objects_count = 0
37
+ objects_to_upload = []
38
+ remote_object_hashes = list_object_hashes
39
+
40
+ commit.object_tree.each do |chdir, objects|
41
+ objects.each do |(mode, type, object_hash, path)|
42
+ objects_count += 1
43
+ path = File.join chdir.delete_prefix(commit.working_dir), path
44
+ manifest << [ mode, type, object_hash, path.delete_prefix("/") ]
45
+
46
+ next if remote_object_hashes.include?(object_hash)
47
+ objects_to_upload << [ chdir, object_hash ]
48
+ end
49
+ end
50
+
51
+ upload_git_objects(objects_to_upload)
52
+ upload_manifest manifest_key(commit.object_tree_checksum), manifest
53
+ end
54
+
55
+ def pull(object_tree_checksum, target_dir)
56
+ puts "Pulling #{object_tree_checksum} into #{target_dir}"
57
+
58
+ manifest = fetch_manifest(object_tree_checksum)
59
+ FileUtils.mkdir_p(target_dir)
60
+
61
+ puts "Downloading #{manifest.size} files"
62
+ pool = Concurrent::FixedThreadPool.new(CONCURRENCY)
63
+
64
+ manifest.each do |(mode, type, object_hash, path)|
65
+ pool.post do
66
+ target_path = File.join target_dir, path
67
+
68
+ if mode == Envirobly::Git::Commit::SYMLINK_FILE_MODE
69
+ fetch_symlink(object_hash, target_path:)
70
+ else
71
+ fetch_object(object_hash, target_path:)
72
+
73
+ if mode == Envirobly::Git::Commit::EXECUTABLE_FILE_MODE
74
+ FileUtils.chmod("+x", target_path)
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ pool.shutdown
81
+ pool.wait_for_termination
82
+ end
83
+
84
+ private
85
+ def list_object_hashes
86
+ @client.list_objects({
87
+ bucket: @bucket,
88
+ prefix: "#{OBJECTS_PREFIX}/"
89
+ }).map do |response|
90
+ response.contents.map do |object|
91
+ object.key.delete_prefix("#{OBJECTS_PREFIX}/").delete_suffix(".gz")
92
+ end
93
+ end.flatten
94
+ end
95
+
96
+ def object_key(object_hash)
97
+ "#{OBJECTS_PREFIX}/#{object_hash}.gz"
98
+ end
99
+
100
+ def manifest_key(object_tree_checksum)
101
+ "#{MANIFESTS_PREFIX}/#{object_tree_checksum}.gz"
102
+ end
103
+
104
+ def object_exists?(key)
105
+ @client.head_object(bucket: @bucket, key:)
106
+ true
107
+ rescue Aws::S3::Errors::NotFound
108
+ false
109
+ end
110
+
111
+ def compress_and_upload_object(object_hash, chdir:)
112
+ key = object_key object_hash
113
+
114
+ Tempfile.create([ "envirobly-push", ".gz" ]) do |tempfile|
115
+ gz = Zlib::GzipWriter.new(tempfile)
116
+
117
+ Open3.popen3("git", "cat-file", "-p", object_hash, chdir:) do |_, stdout, stderr, thread|
118
+ IO.copy_stream(stdout, gz)
119
+
120
+ unless thread.value.success?
121
+ raise "`git cat-file -p #{object_hash}` failed: #{stderr.read}"
122
+ end
123
+ ensure
124
+ gz.close
125
+ end
126
+
127
+ @client.put_object(bucket: @bucket, body: tempfile, key:)
128
+
129
+ # puts "⤴ #{key}"
130
+ end
131
+ end
132
+
133
+ def upload_git_objects(objects)
134
+ pool = Concurrent::FixedThreadPool.new(CONCURRENCY)
135
+ uploaded = Concurrent::AtomicFixnum.new
136
+ objects_count = objects.count
137
+
138
+ notifier = Thread.new do
139
+ next unless objects_count > 0
140
+
141
+ # Hide cursor
142
+ # print "\e[?25l"
143
+ # $stdout.flush
144
+
145
+ loop do
146
+ value = uploaded.value
147
+ print "\rUploading build context files: #{value}/#{objects_count}"
148
+ $stdout.flush
149
+ sleep 0.5
150
+ break if value >= objects_count
151
+ end
152
+
153
+ # Show cursor again
154
+ # print "\e[?25h\n"
155
+ end
156
+
157
+ objects.each do |(chdir, object_hash)|
158
+ pool.post do
159
+ compress_and_upload_object(object_hash, chdir:)
160
+ uploaded.increment
161
+ end
162
+ end
163
+
164
+ pool.shutdown
165
+ pool.wait_for_termination
166
+ notifier.join
167
+ end
168
+
169
+ def upload_manifest(key, content)
170
+ Tempfile.create([ "envirobly-push", ".gz" ]) do |tempfile|
171
+ gz = Zlib::GzipWriter.new(tempfile)
172
+ gz.write JSON.dump(content)
173
+ gz.close
174
+
175
+ @client.put_object(bucket: @bucket, body: tempfile, key:)
176
+
177
+ # puts "⤴ #{key}"
178
+ end
179
+ end
180
+
181
+ def fetch_manifest(ref)
182
+ stream = @bucket_resource.object(manifest_key(ref)).get.body
183
+ JSON.parse Zlib::GzipReader.new(stream).read
184
+ rescue Aws::S3::Errors::NoSuchKey
185
+ puts "Commit #{ref} doesn't exist at s3://#{@bucket}"
186
+ exit 1
187
+ end
188
+
189
+ def fetch_object(object_hash, target_path:)
190
+ FileUtils.mkdir_p File.dirname(target_path)
191
+
192
+ key = object_key object_hash
193
+ stream = @bucket_resource.object(key).get.body
194
+
195
+ File.open(target_path, "wb") do |target|
196
+ gz = Zlib::GzipReader.new(stream)
197
+ IO.copy_stream(gz, target)
198
+ gz.close
199
+ end
200
+ end
201
+
202
+ def fetch_symlink(object_hash, target_path:)
203
+ FileUtils.mkdir_p File.dirname(target_path)
204
+
205
+ key = object_key object_hash
206
+ gz = Zlib::GzipReader.new @bucket_resource.object(key).get.body
207
+ symlink_to = gz.read
208
+ gz.close
209
+
210
+ FileUtils.ln_s symlink_to, target_path
211
+ end
212
+
213
+ def format_duration(duration)
214
+ total_seconds = duration.to_i
215
+ minutes = (total_seconds / 60).floor
216
+ seconds = (total_seconds % 60).ceil
217
+ result = [ "#{seconds}s" ]
218
+ result.prepend "#{minutes}m" if minutes > 0
219
+ result.join " "
220
+ end
221
+ end
@@ -1,55 +1,107 @@
1
1
  class Envirobly::Cli::Main < Envirobly::Base
2
+ include Envirobly::Colorize
3
+
2
4
  desc "version", "Show Envirobly CLI version"
5
+ method_option :pure, type: :boolean, default: false
3
6
  def version
4
- puts Envirobly::VERSION
7
+ if options.pure
8
+ puts Envirobly::VERSION
9
+ else
10
+ puts "envirobly CLI v#{Envirobly::VERSION}"
11
+ end
12
+ end
13
+
14
+ desc "signin", "Set access token generated at Envirobly"
15
+ def signin
16
+ access_token = Envirobly::AccessToken.new(shell:)
17
+ access_token.set
18
+ end
19
+
20
+ desc "signout", "Sign out"
21
+ def signout
22
+ Envirobly::AccessToken.destroy
23
+ say "You've signed out."
24
+ say "This didn't delete the access token itself."
25
+ say "You can sign in again with `envirobly signin`."
26
+ end
27
+
28
+ desc "set_default_account", "Choose default account to deploy the current project to"
29
+ def set_default_account
30
+ Envirobly::Defaults::Account.new(shell:).require_id
31
+ end
32
+
33
+ desc "set_default_region", "Set default region for the current project when deploying for the first time"
34
+ def set_default_region
35
+ Envirobly::Defaults::Region.new(shell:).require_id
5
36
  end
6
37
 
7
38
  desc "validate", "Validates config"
8
39
  def validate
9
- commit = Envirobly::Git::Unstaged.new
10
- config = Envirobly::Config.new(commit)
11
- config.validate
12
-
13
- if config.errors.any?
14
- puts "Issues found validating `#{Envirobly::Config::PATH}`:"
15
- puts
16
- config.errors.each_with_index do |error, index|
17
- puts " #{index + 1}. #{error}"
18
- end
19
- puts
20
- exit 1
40
+ Envirobly::AccessToken.new(shell:).require!
41
+
42
+ configs = Envirobly::Config.new
43
+ api = Envirobly::Api.new
44
+
45
+ params = { validation: configs.to_params }
46
+ response = api.validate_shape params
47
+
48
+ if response.object.fetch("valid")
49
+ puts "Config is valid #{green_check}"
21
50
  else
22
- puts "All checks pass."
51
+ display_config_errors response.object.fetch("errors")
52
+ exit 1
23
53
  end
24
54
  end
25
55
 
26
- desc "deploy ENVIRONMENT", "Deploy to environment identified by name or URL"
56
+ desc "deploy [ENVIRON_NAME]", <<~TXT
57
+ Deploy to environ identified by name.
58
+ Name can contain letters, numbers, dashes or underscores.
59
+ If environ name is left blank, current git branch name is used.
60
+ TXT
61
+ method_option :account_id, type: :numeric
62
+ method_option :region, type: :string
63
+ method_option :project, type: :string
27
64
  method_option :commit, type: :string, default: "HEAD"
28
65
  method_option :dry_run, type: :boolean, default: false
29
- def deploy(environment)
30
- abort_if_aws_cli_is_missing
31
- Envirobly::Deployment.new environment, options
32
- end
33
-
34
- desc "set_access_token TOKEN", "Save and use an access token generated at Envirobly"
35
- def set_access_token
36
- token = ask("Access Token:", echo: false).strip
66
+ def deploy(environ_name = nil)
67
+ commit = Envirobly::Git::Commit.new options.commit
37
68
 
38
- if token.blank?
39
- $stderr.puts
40
- $stderr.puts "Token can't be empty."
69
+ unless commit.exists?
70
+ say_error "Commit '#{commit.ref}' doesn't exist in this repository. Aborting."
41
71
  exit 1
42
72
  end
43
73
 
44
- Envirobly::AccessToken.new(token).save
45
- end
74
+ Envirobly::AccessToken.new(shell:).require!
46
75
 
47
- private
48
- def abort_if_aws_cli_is_missing
49
- `which aws`
50
- unless $?.success?
51
- $stderr.puts "AWS CLI is missing. Please install it first: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
52
- exit 1
76
+ environ_name = environ_name.presence || commit.current_branch
77
+ project_name = nil
78
+ project_id = nil
79
+
80
+ if options.project.present?
81
+ if options.project =~ Envirobly::Defaults::Project.regexp
82
+ project_id = $1.to_i
83
+ else
84
+ project_name = options.project
53
85
  end
54
86
  end
87
+
88
+ deployment = Envirobly::Deployment.new(
89
+ account_id: options.account_id,
90
+ region: options.region,
91
+ project_name:,
92
+ environ_name:,
93
+ project_id:,
94
+ commit:,
95
+ shell:
96
+ )
97
+ deployment.perform(dry_run: options.dry_run)
98
+ end
99
+
100
+ desc "pull", "Download build context"
101
+ def pull(region, bucket, ref, path)
102
+ Envirobly::Duration.measure("Build context download took %s") do
103
+ s3 = Envirobly::Aws::S3.new(region:, bucket:)
104
+ s3.pull ref, path
105
+ end
106
+ end
55
107
  end
@@ -0,0 +1,54 @@
1
+ module Envirobly::Colorize
2
+ GREEN = "\e[32m"
3
+ RED = "\e[31m"
4
+ YELLOW = "\e[33m"
5
+ BLUE = "\e[34m"
6
+ RESET = "\e[0m"
7
+ BOLD = "\e[1m"
8
+ FAINT = "\e[2m"
9
+
10
+ def faint(text)
11
+ [ FAINT, text, RESET ].join
12
+ end
13
+
14
+ def bold(text)
15
+ [ BOLD, text, RESET ].join
16
+ end
17
+
18
+ def green(text)
19
+ [ GREEN, text, RESET ].join
20
+ end
21
+
22
+ def yellow(text)
23
+ [ YELLOW, text, RESET ].join
24
+ end
25
+
26
+ def red(text)
27
+ [ RED, text, RESET ].join
28
+ end
29
+
30
+ def green_check
31
+ green("✔")
32
+ end
33
+
34
+ def downwards_arrow_to_right
35
+ "↳"
36
+ end
37
+
38
+ def cross
39
+ "✖"
40
+ end
41
+
42
+ def display_config_errors(errors)
43
+ puts "#{red(cross)} Config contains the following issues:"
44
+
45
+ errors.each do |error|
46
+ puts
47
+ puts " #{error["message"]}"
48
+
49
+ if error["path"]
50
+ puts faint(" #{downwards_arrow_to_right} #{error["path"]}")
51
+ end
52
+ end
53
+ end
54
+ end