envirobly 0.7.2 → 0.10.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: 1502f5b98c25be6da48fbdd5ebf525723d8985d4e45eef5693611a0aea9db82a
4
+ data.tar.gz: 5684ae39ecb8f3f7006979e849a9746d17bb33dd849f8173836d2b9afa1cc6cb
5
5
  SHA512:
6
- metadata.gz: f0d071e480b64d91210ae0eb422d29707950c3288ca7e8a85354bfbaff1e78bec44e0bb88606edf264aab94bf2478e85fd6a85fba1f920e2b028c55c457bdc3b
7
- data.tar.gz: ee38e1c77c0b66b90d4b36d3c89a217caef7f71f1e9c34d32ac4731eba898b265519e018501a5d5ebe41f644ca5e8534e04cf07b4ef5afa592595f60ddd5f453
6
+ metadata.gz: 8273cfe4dddd1f1da589add660066d5bd2b3c70f1ff0df92f0461f9b4c0b416f99276cb1a69ea1e1f13529ab6e18aca9f3720bb5e3896f8403251ee58e935bfd
7
+ data.tar.gz: a9e26055d4d0de16d449aec2ddc23921d61496969eb2ebd5812dc0279a8d2a1848c5e9ea074bfe5ea7c1f35c89af69162510ecfc88c5b8f7fa137c0241120c76
data/lib/envirobly/api.rb CHANGED
@@ -12,22 +12,33 @@ class Envirobly::Api
12
12
  @access_token = Envirobly::AccessToken.new
13
13
  end
14
14
 
15
+ def validate_shape(params)
16
+ post_as_json(api_v1_shape_validations_url, params:, headers: authorization_headers).tap do |response|
17
+ unless successful_response?(response)
18
+ $stderr.puts "Validation request responded with #{response.code}. Aborting."
19
+ exit 1
20
+ end
21
+ end
22
+ end
23
+
15
24
  def create_deployment(params)
16
25
  post_as_json(api_v1_deployments_url, params:, headers: authorization_headers).tap do |response|
17
- unless response.code.to_i == 200
26
+ unless successful_response?(response)
18
27
  $stderr.puts "Deployment creation request responded with #{response.code}. Aborting."
28
+ # TODO: render 422 validation failed nicely
29
+ $stderr.puts response.object
19
30
  exit 1
20
31
  end
21
32
  end
22
33
  end
23
34
 
24
- RETRY_INTERVAL_SECONDS = 3
25
- MAX_RETRIES = 5
35
+ MAX_RETRIES = 20
36
+ RETRY_INTERVAL_SECONDS = 2
26
37
  def get_deployment_with_delay_and_retry(url, tries = 1)
27
38
  sleep RETRY_INTERVAL_SECONDS * tries
28
39
  response = get_as_json URI(url)
29
40
 
30
- if response.code.to_i == 200
41
+ if successful_response?(response)
31
42
  response
32
43
  elsif MAX_RETRIES <= tries
33
44
  $stderr.puts "Max retries exhausted while waiting for deployment credentials. Aborting."
@@ -55,6 +66,10 @@ class Envirobly::Api
55
66
  end
56
67
 
57
68
  private
69
+ def api_v1_shape_validations_url
70
+ URI::HTTPS.build(host: HOST, path: "/api/v1/shape_validations")
71
+ end
72
+
58
73
  def api_v1_deployments_url
59
74
  URI::HTTPS.build(host: HOST, path: "/api/v1/deployments")
60
75
  end
@@ -74,7 +89,7 @@ class Envirobly::Api
74
89
 
75
90
  http.request(request).tap do |response|
76
91
  def response.object
77
- @json_parsed_body ||= JSON.parse body
92
+ @json_parsed_body ||= JSON.parse(body)
78
93
  end
79
94
  end
80
95
  end
@@ -86,4 +101,8 @@ class Envirobly::Api
86
101
  def authorization_headers
87
102
  { "Authorization" => @access_token.as_http_bearer }
88
103
  end
104
+
105
+ def successful_response?(response)
106
+ (200..299).include?(response.code.to_i)
107
+ end
89
108
  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,34 +1,56 @@
1
1
  class Envirobly::Cli::Main < Envirobly::Base
2
2
  desc "version", "Show Envirobly CLI version"
3
+ method_option :pure, type: :boolean, default: false
3
4
  def version
4
- puts Envirobly::VERSION
5
+ if options.pure
6
+ puts Envirobly::VERSION
7
+ else
8
+ puts "envirobly CLI v#{Envirobly::VERSION}"
9
+ end
5
10
  end
6
11
 
7
12
  desc "validate", "Validates config"
8
13
  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}"
14
+ configs = Envirobly::Configs.new
15
+ api = Envirobly::Api.new
16
+
17
+ params = { validation: configs.to_params }
18
+ response = api.validate_shape params
19
+
20
+ if response.object.fetch("valid")
21
+ puts "All checks pass."
22
+ else
23
+ response.object.fetch("errors").each do |config_path, messages|
24
+ puts "#{config_path}:"
25
+ puts
26
+ messages.each_with_index do |message, index|
27
+ puts " #{message}"
28
+ puts
29
+ end
18
30
  end
19
- puts
31
+
20
32
  exit 1
21
- else
22
- puts "All checks pass."
23
33
  end
24
34
  end
25
35
 
26
- desc "deploy ENVIRONMENT", "Deploy to environment identified by name or URL"
36
+ desc "deploy [ENVIRON_NAME]", <<~TXT
37
+ Deploy to environment identified by name.
38
+ When name is empty, current git branch name is used.
39
+ TXT
27
40
  method_option :commit, type: :string, default: "HEAD"
28
41
  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
42
+ method_option :account_id, type: :numeric
43
+ method_option :project_name, type: :string
44
+ method_option :project_region, type: :string
45
+ def deploy(environ_name = Envirobly::Git.new.current_branch)
46
+ deployment = Envirobly::Deployment.new(
47
+ environ_name:,
48
+ commit_ref: options.commit,
49
+ account_id: options.account_id,
50
+ project_name: options.project_name,
51
+ project_region: options.project_region
52
+ )
53
+ deployment.perform(dry_run: options.dry_run)
32
54
  end
33
55
 
34
56
  desc "set_access_token TOKEN", "Save and use an access token generated at Envirobly"
@@ -44,12 +66,47 @@ class Envirobly::Cli::Main < Envirobly::Base
44
66
  Envirobly::AccessToken.new(token).save
45
67
  end
46
68
 
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
53
- end
69
+ desc "push", "Push commit manifest and blobs to S3"
70
+ def push(region, bucket, ref = "HEAD")
71
+ commit = Envirobly::Git::Commit.new ref
72
+ s3 = Envirobly::Aws::S3.new(region:, bucket:)
73
+ s3.push commit
74
+ end
75
+
76
+ desc "pull", "Download working copy from S3"
77
+ def pull(region, bucket, ref, path)
78
+ Envirobly::Duration.measure("Build context download took %s") do
79
+ s3 = Envirobly::Aws::S3.new(region:, bucket:)
80
+ s3.pull ref, path
54
81
  end
82
+ end
83
+
84
+ desc "object_tree", "Show object tree used for deployments"
85
+ method_option :commit, type: :string, default: "HEAD"
86
+ def object_tree
87
+ commit = Envirobly::Git::Commit.new options.commit
88
+ puts "Commit: #{commit.ref}"
89
+ pp commit.object_tree
90
+ puts "SHA256: #{commit.object_tree_checksum}"
91
+ end
92
+
93
+ desc "measure", "POC of Envirobly::Duration"
94
+ def measure
95
+ Envirobly::Duration.measure do
96
+ print "Doing something for 2s"
97
+ sleep 2
98
+ end
99
+
100
+ Envirobly::Duration.measure do
101
+ print "Doing something else for 100ms"
102
+ sleep 0.1
103
+ end
104
+
105
+ Envirobly::Duration.measure("Custom message, took %s") do
106
+ puts "Sleeping 2.5s with custom message"
107
+ sleep 2.5
108
+ end
109
+
110
+ puts "Done."
111
+ end
55
112
  end
@@ -0,0 +1,21 @@
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 green(text)
15
+ [ GREEN, text, RESET ].join
16
+ end
17
+
18
+ def yellow(text)
19
+ [ YELLOW, text, RESET ].join
20
+ end
21
+ end
@@ -0,0 +1,79 @@
1
+ require "dotenv"
2
+
3
+ class Envirobly::Configs
4
+ DIR = ".envirobly"
5
+ ENV = "env"
6
+ BASE = "deploy.yml"
7
+ OVERRIDES_PATTERN = /deploy\.([a-z0-9\-_]+)\.yml/i
8
+ DEFAULTS_DIR = File.join DIR, "defaults"
9
+ DEFAULT_ACCOUNT_PATH = File.join(DEFAULTS_DIR, "account.yml")
10
+ DEFAULT_PROJECT_PATH = File.join(DEFAULTS_DIR, "project.yml")
11
+
12
+ def initialize(dir = DIR)
13
+ @dir = Pathname.new dir
14
+ end
15
+
16
+ def to_params
17
+ {
18
+ configs:,
19
+ env_vars:
20
+ }
21
+ end
22
+
23
+ def default_project_id
24
+ if File.exist?(DEFAULT_PROJECT_PATH)
25
+ content = YAML.safe_load_file(DEFAULT_PROJECT_PATH)
26
+ if content["url"] =~ /projects\/(\d+)/
27
+ return $1.to_i
28
+ end
29
+ end
30
+
31
+ nil
32
+ end
33
+
34
+ def default_account_id
35
+ if File.exist?(DEFAULT_ACCOUNT_PATH)
36
+ content = YAML.safe_load_file(DEFAULT_ACCOUNT_PATH)
37
+ if content["url"] =~ /accounts\/(\d+)/
38
+ return $1.to_i
39
+ end
40
+ end
41
+
42
+ nil
43
+ end
44
+
45
+ def save_default_account(url)
46
+ return if File.exist?(DEFAULT_ACCOUNT_PATH)
47
+
48
+ FileUtils.mkdir_p(DEFAULTS_DIR)
49
+ content = YAML.dump({ "url" => url })
50
+ File.write(DEFAULT_ACCOUNT_PATH, content)
51
+ end
52
+
53
+ def save_default_project(url)
54
+ return if File.exist?(DEFAULT_PROJECT_PATH)
55
+
56
+ FileUtils.mkdir_p(DEFAULTS_DIR)
57
+ content = YAML.dump({ "url" => url })
58
+ File.write(DEFAULT_PROJECT_PATH, content)
59
+ end
60
+
61
+ private
62
+ def configs
63
+ Dir.entries(@dir).map do |file|
64
+ path = File.join(@dir, file)
65
+
66
+ next unless File.file?(path) && config_file?(file)
67
+
68
+ [ "#{DIR}/#{file}", File.read(path) ]
69
+ end.compact.to_h
70
+ end
71
+
72
+ def env_vars
73
+ Dotenv.parse @dir.join(ENV)
74
+ end
75
+
76
+ def config_file?(file)
77
+ file == BASE || file.match?(OVERRIDES_PATTERN)
78
+ end
79
+ end
@@ -1,50 +1,88 @@
1
+ require "yaml"
2
+
1
3
  class Envirobly::Deployment
2
- def initialize(environment, options)
3
- commit = Envirobly::Git::Commit.new options.commit
4
+ include Envirobly::Colorize
5
+
6
+ def initialize(environ_name:, commit_ref:, account_id:, project_name:, project_region:)
7
+ @environ_name = environ_name
8
+ @commit = Envirobly::Git::Commit.new commit_ref
4
9
 
5
- unless commit.exists?
6
- $stderr.puts "Commit #{options.commit} doesn't exist in this repository. Aborting."
10
+ unless @commit.exists?
11
+ $stderr.puts "Commit #{commit_ref} doesn't exist in this repository. Aborting."
7
12
  exit 1
8
13
  end
9
14
 
10
- config = Envirobly::Config.new(commit)
11
- config.validate
12
-
13
- if config.errors.any?
14
- $stderr.puts "Errors found while parsing #{Envirobly::Config::PATH}:"
15
- $stderr.puts
16
- config.errors.each do |error|
17
- $stderr.puts " - #{error}"
18
- end
19
- $stderr.puts
20
- $stderr.puts "Please fix these, commit the changes and try again."
21
- exit 1
15
+ @configs = Envirobly::Configs.new
16
+
17
+ if account_id.nil?
18
+ account_id = @configs.default_account_id
22
19
  end
23
20
 
24
- config.compile(environment)
25
- params = config.to_deployment_params
21
+ project_id = nil
22
+ if project_name.nil?
23
+ project_id = @configs.default_project_id
24
+ end
25
+
26
+ @params = {
27
+ account: {
28
+ id: account_id
29
+ },
30
+ project: {
31
+ id: project_id,
32
+ name: project_name,
33
+ region: project_region
34
+ },
35
+ deployment: {
36
+ environ_name:,
37
+ commit_ref: @commit.ref,
38
+ commit_time: @commit.time,
39
+ commit_message: @commit.message,
40
+ object_tree_checksum: @commit.object_tree_checksum,
41
+ **@configs.to_params
42
+ }
43
+ }
44
+ end
26
45
 
27
- puts "Deployment config:"
28
- puts params.to_yaml
46
+ def perform(dry_run:)
47
+ puts [ "Deploying commit", yellow(@commit.short_ref), faint("→"), green(@environ_name) ].join(" ")
48
+ puts
49
+ puts " #{@commit.message}"
50
+ puts
29
51
 
30
- exit if options.dry_run?
52
+ if dry_run
53
+ puts YAML.dump(@params)
54
+ return
55
+ end
31
56
 
57
+ # Create deployment
32
58
  api = Envirobly::Api.new
33
- response = api.create_deployment params
34
- deployment_url = response.object.fetch("url")
35
- response = api.get_deployment_with_delay_and_retry deployment_url
36
- credentials = Envirobly::Aws::Credentials.new response.object.fetch("credentials")
37
- bucket = response.object.fetch("bucket")
38
-
39
- puts "Uploading build context, please wait..."
40
- unless commit.archive_and_upload(bucket:, credentials:).success?
41
- $stderr.puts "Error exporting build context. Aborting."
42
- exit 1
59
+
60
+ Envirobly::Duration.measure do
61
+ print "Preparing project..."
62
+ response = api.create_deployment @params
63
+
64
+ @configs.save_default_account(response.object.fetch("account_url"))
65
+ @configs.save_default_project(response.object.fetch("project_url"))
66
+
67
+ # Fetch credentials for build context upload
68
+ @deployment_url = response.object.fetch("url")
69
+ @credentials_response = api.get_deployment_with_delay_and_retry @deployment_url
43
70
  end
44
71
 
45
- puts "Build context uploaded."
46
- api.put_as_json deployment_url
72
+ credentials = @credentials_response.object.fetch("credentials")
73
+ region = @credentials_response.object.fetch("region")
74
+ bucket = @credentials_response.object.fetch("bucket")
75
+ watch_deployment_url = @credentials_response.object.fetch("deployment_url")
76
+
77
+ Envirobly::Duration.measure do
78
+ # Upload build context
79
+ s3 = Envirobly::Aws::S3.new(bucket:, region:, credentials:)
80
+ s3.push @commit
81
+
82
+ # Perform deployment
83
+ api.put_as_json @deployment_url
84
+ end
47
85
 
48
- # TODO: Output URL to watch the deployment progress
86
+ puts "Follow at #{watch_deployment_url}"
49
87
  end
50
88
  end
@@ -0,0 +1,36 @@
1
+ require "benchmark"
2
+
3
+ class Envirobly::Duration
4
+ class << self
5
+ include Envirobly::Colorize
6
+
7
+ def measure(message = nil)
8
+ measurement = Benchmark.measure do
9
+ yield
10
+ end
11
+
12
+ duration = format_duration(measurement)
13
+
14
+ if message.nil?
15
+ puts [ "", green("✔"), faint(duration) ].join(" ")
16
+ else
17
+ puts sprintf(message, duration)
18
+ end
19
+ end
20
+
21
+ def format_duration(tms)
22
+ ms = (tms.real * 1000).to_i
23
+
24
+ if ms >= 60_000
25
+ minutes = ms / 60_000
26
+ seconds = (ms % 60_000) / 1000
27
+ sprintf("%dm%ds", minutes, seconds)
28
+ elsif ms >= 1000
29
+ seconds = ms / 1000
30
+ sprintf("%ds", seconds)
31
+ else
32
+ sprintf("%dms", ms)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -1,10 +1,15 @@
1
1
  require "time"
2
2
  require "open3"
3
3
 
4
- class Envirobly::Git::Commit
4
+ class Envirobly::Git::Commit < Envirobly::Git
5
+ EXECUTABLE_FILE_MODE = "100755"
6
+ SYMLINK_FILE_MODE = "120000"
7
+
8
+ attr_reader :working_dir
9
+
5
10
  def initialize(ref, working_dir: Dir.getwd)
6
11
  @ref = ref
7
- @working_dir = working_dir
12
+ super working_dir
8
13
  end
9
14
 
10
15
  def exists?
@@ -15,6 +20,10 @@ class Envirobly::Git::Commit
15
20
  @normalized_ref ||= git(%(rev-parse #{@ref})).stdout.strip
16
21
  end
17
22
 
23
+ def short_ref
24
+ @short_ref ||= ref[0..6]
25
+ end
26
+
18
27
  def message
19
28
  git(%(log #{@ref} -n1 --pretty=%B)).stdout.strip
20
29
  end
@@ -36,25 +45,35 @@ class Envirobly::Git::Commit
36
45
  git(%(show #{@ref}:#{path})).stdout
37
46
  end
38
47
 
39
- def objects_with_checksum_at(path)
40
- git(%{ls-tree #{@ref} --format='%(objectname) %(path)' #{path}}).stdout.lines.map(&:chomp).
41
- reject { _1.split(" ").last == Envirobly::Config::DIR }
42
- end
48
+ def object_tree(ref: @ref, chdir: @working_dir)
49
+ @object_tree ||= begin
50
+ objects = {}
51
+ objects[chdir] = []
43
52
 
44
- def archive_and_upload(bucket:, credentials:)
45
- git(%(archive --format=tar.gz #{ref} | #{credentials.as_inline_env_vars} aws s3 cp - #{archive_uri(bucket)}))
46
- end
53
+ git(%(ls-tree -r #{ref}), chdir:).stdout.lines.each do |line|
54
+ mode, type, object_hash, path = line.split(/\s+/)
55
+
56
+ next if path.start_with?("#{Envirobly::Configs::DIR}/")
47
57
 
48
- private
49
- OUTPUT = Struct.new :stdout, :stderr, :exit_code, :success?
50
- def git(cmd)
51
- Open3.popen3("git #{cmd}", chdir: @working_dir) do |stdin, stdout, stderr, thread|
52
- stdin.close
53
- OUTPUT.new stdout.read, stderr.read, thread.value.exitstatus, thread.value.success?
58
+ if type == "commit"
59
+ objects.merge! object_tree(ref: object_hash, chdir: File.join(chdir, path))
60
+ else
61
+ objects[chdir] << [ mode, type, object_hash, path ]
62
+ end
54
63
  end
55
- end
56
64
 
57
- def archive_uri(bucket)
58
- "s3://#{bucket}/#{ref}.tar.gz"
65
+ objects
59
66
  end
67
+ end
68
+
69
+ def object_tree_checksum
70
+ digestable = object_tree.values.flatten.to_json
71
+ @object_tree_checksum ||= Digest::SHA256.hexdigest(digestable)
72
+ end
73
+
74
+ # @deprecated
75
+ def objects_with_checksum_at(path)
76
+ git(%{ls-tree #{@ref} --format='%(objectname) %(path)' #{path}}).stdout.lines.map(&:chomp).
77
+ reject { _1.split(" ").last == Envirobly::Configs::DIR }
78
+ end
60
79
  end
data/lib/envirobly/git.rb CHANGED
@@ -1,2 +1,17 @@
1
- module Envirobly::Git
1
+ class Envirobly::Git
2
+ def initialize(working_dir = Dir.getwd)
3
+ @working_dir = working_dir
4
+ end
5
+
6
+ OUTPUT = Struct.new :stdout, :stderr, :exit_code, :success?
7
+ def git(cmd, chdir: @working_dir)
8
+ Open3.popen3("git #{cmd}", chdir:) do |stdin, stdout, stderr, thread|
9
+ stdin.close
10
+ OUTPUT.new stdout.read, stderr.read, thread.value.exitstatus, thread.value.success?
11
+ end
12
+ end
13
+
14
+ def current_branch
15
+ git("branch --show-current").stdout.strip
16
+ end
2
17
  end
@@ -1,3 +1,3 @@
1
1
  module Envirobly
2
- VERSION = "0.7.2"
2
+ VERSION = "0.10.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: envirobly
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Starsi
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-10-28 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: thor
@@ -52,6 +51,48 @@ dependencies:
52
51
  - - "~>"
53
52
  - !ruby/object:Gem::Version
54
53
  version: 0.1.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: aws-sdk-s3
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.182'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.182'
68
+ - !ruby/object:Gem::Dependency
69
+ name: concurrent-ruby
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.3'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.3'
82
+ - !ruby/object:Gem::Dependency
83
+ name: dotenv
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.1'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.1'
55
96
  - !ruby/object:Gem::Dependency
56
97
  name: debug
57
98
  requirement: !ruby/object:Gem::Requirement
@@ -122,7 +163,6 @@ dependencies:
122
163
  - - ">="
123
164
  - !ruby/object:Gem::Version
124
165
  version: '0'
125
- description:
126
166
  email: klevo@klevo.sk
127
167
  executables:
128
168
  - envirobly
@@ -137,11 +177,14 @@ files:
137
177
  - lib/envirobly/api.rb
138
178
  - lib/envirobly/aws.rb
139
179
  - lib/envirobly/aws/credentials.rb
180
+ - lib/envirobly/aws/s3.rb
140
181
  - lib/envirobly/base.rb
141
182
  - lib/envirobly/cli.rb
142
183
  - lib/envirobly/cli/main.rb
143
- - lib/envirobly/config.rb
184
+ - lib/envirobly/colorize.rb
185
+ - lib/envirobly/configs.rb
144
186
  - lib/envirobly/deployment.rb
187
+ - lib/envirobly/duration.rb
145
188
  - lib/envirobly/git.rb
146
189
  - lib/envirobly/git/commit.rb
147
190
  - lib/envirobly/git/unstaged.rb
@@ -150,7 +193,6 @@ homepage: https://github.com/envirobly/envirobly-cli
150
193
  licenses:
151
194
  - MIT
152
195
  metadata: {}
153
- post_install_message:
154
196
  rdoc_options: []
155
197
  require_paths:
156
198
  - lib
@@ -165,8 +207,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
165
207
  - !ruby/object:Gem::Version
166
208
  version: '0'
167
209
  requirements: []
168
- rubygems_version: 3.5.22
169
- signing_key:
210
+ rubygems_version: 3.6.7
170
211
  specification_version: 4
171
212
  summary: Envirobly command line interface
172
213
  test_files: []
@@ -1,208 +0,0 @@
1
- require "yaml"
2
- require "json"
3
- require "digest"
4
-
5
- class Envirobly::Config
6
- DIR = ".envirobly"
7
- PATH = "#{DIR}/project.yml"
8
-
9
- attr_reader :errors, :result, :raw
10
-
11
- def initialize(commit)
12
- @commit = commit
13
- @errors = []
14
- @result = {}
15
- @project_url = nil
16
- @raw = @commit.file_content PATH
17
- @project = parse
18
- end
19
-
20
- def dig(*args)
21
- @project.dig(*args)
22
- rescue NoMethodError
23
- nil
24
- end
25
-
26
- def validate
27
- return unless @project
28
- validate_top_level_keys
29
- validate_services @project.fetch(:services)
30
- validate_environments
31
- end
32
-
33
- def compile(environment = nil)
34
- return unless @project
35
- @environment = environment
36
- @result = @project.slice(:services)
37
- set_project_url
38
- merge_environment_overrides! unless @environment.nil?
39
- append_image_tags!
40
- end
41
-
42
- def to_deployment_params
43
- {
44
- environ: {
45
- name: @environment,
46
- project_url: @project_url
47
- },
48
- commit: {
49
- ref: @commit.ref,
50
- time: @commit.time,
51
- message: @commit.message
52
- },
53
- config: @result,
54
- raw_config: @raw
55
- }
56
- end
57
-
58
- private
59
- def parse
60
- YAML.safe_load @raw, aliases: true, symbolize_names: true
61
- rescue Psych::Exception => exception
62
- @errors << exception.message
63
- nil
64
- end
65
-
66
- def set_project_url
67
- @project_url = dig :project
68
- end
69
-
70
- NON_BUILDABLE_TYPES = %w[ postgres mysql valkey ]
71
- BUILD_DEFAULTS = {
72
- dockerfile: [ "Dockerfile", :file_exists? ],
73
- build_context: [ ".", :dir_exists? ]
74
- }
75
- def append_image_tags!
76
- @result[:services].each do |name, service|
77
- next if NON_BUILDABLE_TYPES.include?(service[:type]) || service[:image].present?
78
- checksums = []
79
-
80
- BUILD_DEFAULTS.each do |attribute, options|
81
- value = service.fetch(attribute, options.first)
82
- if @commit.public_send(options.second, value)
83
- checksums << @commit.objects_with_checksum_at(value)
84
- end
85
- end
86
-
87
- if checksums.size == 2
88
- @result[:services][name][:image_tag] = Digest::SHA1.hexdigest checksums.to_json
89
- end
90
- end
91
- end
92
-
93
- def merge_environment_overrides!
94
- return unless services = @project.dig(:environments, @environment.to_sym)
95
- services.each do |name, service|
96
- service.each do |attribute, value|
97
- if value.is_a?(Hash) && @result[:services][name][attribute].is_a?(Hash)
98
- @result[:services][name][attribute].merge! value
99
- @result[:services][name][attribute].compact!
100
- else
101
- @result[:services][name][attribute] = value
102
- end
103
- end
104
- end
105
- end
106
-
107
- VALID_TOP_LEVEL_KEYS = %i[ project services environments ]
108
- def validate_top_level_keys
109
- unless @project.is_a?(Hash)
110
- @errors << "Config doesn't contain a top level hash structure."
111
- return
112
- end
113
-
114
- @errors << "Missing `project: <url>` top level attribute." if @project[:project].blank?
115
-
116
- @project.keys.each do |key|
117
- unless VALID_TOP_LEVEL_KEYS.include?(key)
118
- @errors << "Top level key `#{key}` is not allowed. Allowed keys: #{VALID_TOP_LEVEL_KEYS.map{ "`#{_1}`" }.join(", ")}."
119
- end
120
- end
121
- end
122
-
123
- VALID_SERVICE_KEYS = %i[
124
- type
125
- image
126
- build
127
- engine_version
128
- instance_type
129
- min_instances
130
- max_instances
131
- volume_size
132
- volume_mount
133
- dockerfile
134
- build_context
135
- command
136
- release_command
137
- env
138
- health_check
139
- http
140
- private
141
- aliases
142
- ]
143
- NAME_FORMAT = /\A[a-z0-9\-_\.\/]+\z/i
144
- BUILD_VALUE_FORMAT = /\Av[\d+]\z/
145
- def validate_services(services)
146
- unless services.is_a?(Hash)
147
- @errors << "`services` key must be a hash."
148
- return
149
- end
150
-
151
- services.each do |name, service|
152
- unless name =~ NAME_FORMAT
153
- @errors << "`#{name}` is not a valid service name. Use aplhanumeric characters, dash, underscore, slash or dot."
154
- end
155
-
156
- unless service.is_a?(Hash)
157
- @errors << "Service `#{name}` must be a hash."
158
- next
159
- end
160
-
161
- service.each do |attribute, value|
162
- unless VALID_SERVICE_KEYS.include?(attribute)
163
- @errors << "Service `#{name}` attribute `#{attribute}` is not a valid attribute."
164
- end
165
-
166
- if attribute == :build
167
- unless value =~ BUILD_VALUE_FORMAT
168
- @errors << "Service `#{name}` attribute `#{attribute}` format needs to be a number prefixed with letter \"v\", for example \"v1\"."
169
- end
170
- end
171
- end
172
-
173
- BUILD_DEFAULTS.each do |attribute, options|
174
- value = service.fetch(attribute, options.first)
175
- unless @commit.public_send(options.second, value)
176
- @errors << "Service `#{name}` specifies `#{attribute}` as `#{value}` which doesn't exist in this commit."
177
- end
178
- end
179
-
180
- service.fetch(:env, {}).each do |key, value|
181
- if value.is_a?(Hash) && value.has_key?(:file)
182
- unless @commit.file_exists?(value.fetch(:file))
183
- @errors << "Environment variable `#{key}` referring to a file `#{value.fetch(:file)}` doesn't exist in this commit."
184
- end
185
- end
186
- end
187
- end
188
- end
189
-
190
- def validate_environments
191
- return unless @project.has_key?(:environments)
192
-
193
- environments = @project.fetch :environments, nil
194
-
195
- unless environments.is_a?(Hash)
196
- @errors << "`environments` key must be a hash."
197
- return
198
- end
199
-
200
- environments.each do |environment, services|
201
- unless environment =~ NAME_FORMAT
202
- @errors << "`#{environment}` is not a valid environment name. Use aplhanumeric characters, dash, underscore, slash or dot."
203
- end
204
-
205
- validate_services services
206
- end
207
- end
208
- end