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 +4 -4
- data/lib/envirobly/api.rb +24 -5
- data/lib/envirobly/aws/credentials.rb +5 -0
- data/lib/envirobly/aws/s3.rb +221 -0
- data/lib/envirobly/cli/main.rb +81 -24
- data/lib/envirobly/colorize.rb +21 -0
- data/lib/envirobly/configs.rb +79 -0
- data/lib/envirobly/deployment.rb +72 -34
- data/lib/envirobly/duration.rb +36 -0
- data/lib/envirobly/git/commit.rb +37 -18
- data/lib/envirobly/git.rb +16 -1
- data/lib/envirobly/version.rb +1 -1
- metadata +49 -8
- data/lib/envirobly/config.rb +0 -208
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1502f5b98c25be6da48fbdd5ebf525723d8985d4e45eef5693611a0aea9db82a
|
4
|
+
data.tar.gz: 5684ae39ecb8f3f7006979e849a9746d17bb33dd849f8173836d2b9afa1cc6cb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
-
|
25
|
-
|
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
|
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
|
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
|
@@ -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
|
data/lib/envirobly/cli/main.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
31
|
+
|
20
32
|
exit 1
|
21
|
-
else
|
22
|
-
puts "All checks pass."
|
23
33
|
end
|
24
34
|
end
|
25
35
|
|
26
|
-
desc "deploy
|
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
|
-
|
30
|
-
|
31
|
-
|
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
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
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
|
data/lib/envirobly/deployment.rb
CHANGED
@@ -1,50 +1,88 @@
|
|
1
|
+
require "yaml"
|
2
|
+
|
1
3
|
class Envirobly::Deployment
|
2
|
-
|
3
|
-
|
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 #{
|
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
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
-
|
25
|
-
|
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
|
-
|
28
|
-
puts
|
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
|
-
|
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
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
-
|
46
|
-
|
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
|
-
|
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
|
data/lib/envirobly/git/commit.rb
CHANGED
@@ -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
|
-
|
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
|
40
|
-
|
41
|
-
|
42
|
-
|
48
|
+
def object_tree(ref: @ref, chdir: @working_dir)
|
49
|
+
@object_tree ||= begin
|
50
|
+
objects = {}
|
51
|
+
objects[chdir] = []
|
43
52
|
|
44
|
-
|
45
|
-
|
46
|
-
|
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
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
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
|
-
|
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
|
data/lib/envirobly/version.rb
CHANGED
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.
|
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:
|
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/
|
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.
|
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: []
|
data/lib/envirobly/config.rb
DELETED
@@ -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
|