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.
@@ -1,208 +1,38 @@
1
- require "yaml"
2
- require "json"
3
- require "digest"
1
+ require "dotenv"
4
2
 
5
3
  class Envirobly::Config
6
4
  DIR = ".envirobly"
7
- PATH = "#{DIR}/project.yml"
5
+ BASE = "deploy.yml"
6
+ ENV_VARS = "env"
7
+ OVERRIDES_PATTERN = /deploy\.([a-z0-9\-_]+)\.yml/i
8
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!
9
+ def initialize(dir = DIR)
10
+ @dir = Pathname.new dir
40
11
  end
41
12
 
42
- def to_deployment_params
13
+ def to_params
43
14
  {
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
15
+ configs:,
16
+ env_vars:
55
17
  }
56
18
  end
57
19
 
58
20
  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
21
+ def configs
22
+ Dir.entries(@dir).map do |file|
23
+ path = File.join(@dir, file)
113
24
 
114
- @errors << "Missing `project: <url>` top level attribute." if @project[:project].blank?
25
+ next unless File.file?(path) && config_file?(file)
115
26
 
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
27
+ [ "#{DIR}/#{file}", File.read(path) ]
28
+ end.compact.to_h
121
29
  end
122
30
 
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
31
+ def env_vars
32
+ Dotenv.parse @dir.join(ENV_VARS)
188
33
  end
189
34
 
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
35
+ def config_file?(file)
36
+ file == BASE || file.match?(OVERRIDES_PATTERN)
207
37
  end
208
38
  end
@@ -0,0 +1,47 @@
1
+ class Envirobly::Default
2
+ attr_accessor :shell
3
+
4
+ def self.key = "url"
5
+
6
+ def initialize(shell: nil)
7
+ @path = File.join Envirobly::Config::DIR, "defaults", self.class.file
8
+ @shell = shell
9
+ end
10
+
11
+ def id
12
+ if File.exist?(@path)
13
+ content = YAML.safe_load_file(@path)
14
+
15
+ if content[self.class.key] =~ self.class.regexp
16
+ return cast_id($1)
17
+ end
18
+ end
19
+
20
+ nil
21
+ end
22
+
23
+ def save(url)
24
+ unless url =~ self.class.regexp
25
+ raise ArgumentError, "'#{url}' must match #{self.class.regexp}"
26
+ end
27
+
28
+ FileUtils.mkdir_p(File.dirname(@path))
29
+ content = YAML.dump({ self.class.key => url })
30
+ File.write(@path, content)
31
+ end
32
+
33
+ def save_if_none(url)
34
+ return if id.present?
35
+
36
+ save(url)
37
+ end
38
+
39
+ def require_if_none
40
+ id || require_id
41
+ end
42
+
43
+ private
44
+ def cast_id(value)
45
+ value.to_i
46
+ end
47
+ end
@@ -0,0 +1,46 @@
1
+ class Envirobly::Defaults::Account < Envirobly::Default
2
+ include Envirobly::Colorize
3
+
4
+ def self.file = "account.yml"
5
+ def self.regexp = /accounts\/(\d+)/
6
+
7
+ def require_id
8
+ api = Envirobly::Api.new
9
+ accounts = api.list_accounts
10
+
11
+ if accounts.object.blank?
12
+ shell.say_error "Please connect an AWS account to your Envirobly account first."
13
+ exit 1
14
+ end
15
+
16
+ account = accounts.object.first
17
+ id = account["id"]
18
+
19
+ if accounts.object.size > 1
20
+ puts "Choose default account to deploy this project to:"
21
+
22
+ data = [ [ "ID", "Name", "AWS number", "URL" ] ] +
23
+ accounts.object.pluck("id", "name", "aws_id", "url")
24
+
25
+ shell.print_table data, borders: true
26
+
27
+ limited_to = accounts.object.pluck("id").map(&:to_s)
28
+
29
+ begin
30
+ id = shell.ask("Type in the account ID:", limited_to:).to_i
31
+ rescue Interrupt
32
+ shell.say_error "Cancelled"
33
+ exit
34
+ end
35
+
36
+ account = accounts.object.find { |a| a["id"] == id }
37
+ end
38
+
39
+ save account["url"]
40
+
41
+ shell.say "Account ##{id} set as project default "
42
+ shell.say green_check
43
+
44
+ id
45
+ end
46
+ end
@@ -0,0 +1,4 @@
1
+ class Envirobly::Defaults::Project < Envirobly::Default
2
+ def self.file = "project.yml"
3
+ def self.regexp = /projects\/(\d+)/
4
+ end
@@ -0,0 +1,45 @@
1
+ class Envirobly::Defaults::Region < Envirobly::Default
2
+ include Envirobly::Colorize
3
+
4
+ def self.file = "region.yml"
5
+ def self.regexp = /([a-z0-9\-)]+)/
6
+ def self.key = "code"
7
+
8
+ def require_id
9
+ api = Envirobly::Api.new
10
+ response = api.list_regions
11
+
12
+ shell.say "Choose default project region to deploy to:"
13
+ shell.print_table [ [ "Name", "Location", "Group" ] ] +
14
+ response.object.pluck("code", "title", "group_title"), borders: true
15
+
16
+ code = nil
17
+ limited_to = response.object.pluck("code")
18
+
19
+ while code.nil?
20
+ begin
21
+ code = shell.ask("Type in the region name:", default: "us-east-1")
22
+ rescue Interrupt
23
+ shell.say_error "Cancelled"
24
+ exit
25
+ end
26
+
27
+ unless code.in?(limited_to)
28
+ shell.say_error "'#{code}' is not a supported region, please try again"
29
+ code = nil
30
+ end
31
+ end
32
+
33
+ save code
34
+
35
+ shell.say "Region '#{id}' set as project default "
36
+ shell.say green_check
37
+
38
+ id
39
+ end
40
+
41
+ private
42
+ def cast_id(value)
43
+ value
44
+ end
45
+ end
@@ -0,0 +1,2 @@
1
+ module Envirobly::Defaults
2
+ end
@@ -1,50 +1,99 @@
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:, account_id:, project_name:, project_id:, region:, shell:)
7
+ @environ_name = environ_name
8
+ @commit = commit
9
+ @config = Envirobly::Config.new
10
+ @default_account = Envirobly::Defaults::Account.new(shell:)
11
+ @default_project = Envirobly::Defaults::Project.new(shell:)
12
+ @default_region = Envirobly::Defaults::Region.new(shell:)
4
13
 
5
- unless commit.exists?
6
- $stderr.puts "Commit #{options.commit} doesn't exist in this repository. Aborting."
7
- exit 1
14
+ if account_id.blank?
15
+ account_id = @default_account.require_if_none
8
16
  end
9
17
 
10
- config = Envirobly::Config.new(commit)
11
- config.validate
18
+ if project_id.blank? && project_name.blank?
19
+ project_id = @default_project.id
12
20
 
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}"
21
+ if project_id.nil?
22
+ project_name = File.basename(Dir.pwd)
18
23
  end
19
- $stderr.puts
20
- $stderr.puts "Please fix these, commit the changes and try again."
21
- exit 1
22
24
  end
23
25
 
24
- config.compile(environment)
25
- params = config.to_deployment_params
26
+ if region.blank?
27
+ region = @default_region.require_if_none
28
+ end
29
+
30
+ @params = {
31
+ account: {
32
+ id: account_id
33
+ },
34
+ project: {
35
+ id: project_id,
36
+ name: project_name,
37
+ region:
38
+ },
39
+ deployment: {
40
+ environ_name:,
41
+ commit_ref: @commit.ref,
42
+ commit_time: @commit.time,
43
+ commit_message: @commit.message,
44
+ object_tree_checksum: @commit.object_tree_checksum,
45
+ **@config.to_params
46
+ }
47
+ }
48
+ end
26
49
 
27
- puts "Deployment config:"
28
- puts params.to_yaml
50
+ def perform(dry_run:)
51
+ puts [ "Deploying commit", yellow(@commit.short_ref), faint("→"), green(@environ_name) ].join(" ")
52
+ puts
53
+ puts " #{@commit.message}"
54
+ puts
29
55
 
30
- exit if options.dry_run?
56
+ if dry_run
57
+ puts YAML.dump(@params)
58
+ return
59
+ end
31
60
 
61
+ # Create deployment
32
62
  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
63
+
64
+ Envirobly::Duration.measure do
65
+ response = api.create_deployment @params
66
+
67
+ unless response.success?
68
+ display_config_errors response.object.fetch("errors")
69
+ exit 1
70
+ end
71
+
72
+ print "Preparing project..."
73
+
74
+ @default_account.save_if_none response.object.fetch("account_url")
75
+ @default_project.save_if_none response.object.fetch("project_url")
76
+ @default_region.save_if_none response.object.fetch("region")
77
+
78
+ # Fetch credentials for build context upload
79
+ @deployment_url = response.object.fetch("url")
80
+ @credentials_response = api.get_deployment_with_delay_and_retry @deployment_url
43
81
  end
44
82
 
45
- puts "Build context uploaded."
46
- api.put_as_json deployment_url
83
+ credentials = @credentials_response.object.fetch("credentials")
84
+ region = @credentials_response.object.fetch("region")
85
+ bucket = @credentials_response.object.fetch("bucket")
86
+ watch_deployment_url = @credentials_response.object.fetch("deployment_url")
87
+
88
+ Envirobly::Duration.measure do
89
+ # Upload build context
90
+ s3 = Envirobly::Aws::S3.new(bucket:, region:, credentials:)
91
+ s3.push @commit
92
+
93
+ # Perform deployment
94
+ api.put_as_json @deployment_url
95
+ end
47
96
 
48
- # TODO: Output URL to watch the deployment progress
97
+ puts "Follow at #{watch_deployment_url}"
49
98
  end
50
99
  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_check, 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::Config::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::Config::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 = "1.0.0"
3
3
  end
data/lib/envirobly.rb CHANGED
@@ -1,10 +1,10 @@
1
1
  module Envirobly
2
2
  end
3
3
 
4
+ require "active_support"
5
+ require "active_support/core_ext"
4
6
  require "zeitwerk"
5
- require "core_ext"
6
7
 
7
8
  loader = Zeitwerk::Loader.for_gem
8
- loader.ignore("#{__dir__}/core_ext.rb")
9
9
  loader.setup
10
10
  loader.eager_load