renuo-cli 4.27.0 → 4.28.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/renuo/cli/commands/review_app/deploy.rb +114 -0
- data/lib/renuo/cli/commands/review_app/name.rb +38 -0
- data/lib/renuo/cli/commands/review_app/purge.rb +67 -0
- data/lib/renuo/cli/commands/review_app/setup_semaphoreci.rb +87 -0
- data/lib/renuo/cli/commands/review_app/start.rb +53 -0
- data/lib/renuo/cli/helpers/actor_contracts.rb +16 -0
- data/lib/renuo/cli/helpers/cmd_trail.rb +45 -0
- data/lib/renuo/cli/helpers/review_app_helper.rb +46 -0
- data/lib/renuo/cli/helpers/safe_review_app_name_contract.rb +17 -0
- data/lib/renuo/cli/services/deploio/copy_app.rb +55 -0
- data/lib/renuo/cli/services/deploio/copy_database_data.rb +57 -0
- data/lib/renuo/cli/services/deploio/copy_s3_data.rb +70 -0
- data/lib/renuo/cli/services/deploio/create_database.rb +49 -0
- data/lib/renuo/cli/services/deploio/create_kvs.rb +41 -0
- data/lib/renuo/cli/services/deploio/create_s3.rb +82 -0
- data/lib/renuo/cli/services/deploio/inventorize_origin_resources.rb +174 -0
- data/lib/renuo/cli/services/deploio/start_target_app.rb +19 -0
- data/lib/renuo/cli/services/deploio/update_target_app_env.rb +37 -0
- data/lib/renuo/cli/services/deploio.rb +16 -4
- data/lib/renuo/cli/templates/semaphore/review-app-deploy.yml.erb +44 -0
- data/lib/renuo/cli/templates/semaphore/review-app-purge.yml.erb +21 -0
- data/lib/renuo/cli/version.rb +1 -1
- data/lib/renuo/cli.rb +10 -0
- metadata +49 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 352c9cb95e2c7548026938080ff46625617536757aa4653aa3412ac35dead312
|
|
4
|
+
data.tar.gz: dc0de5b1c6781136fe7b13d441c823b648a5efa36c3d28c1cc74e1f8ed8c2e2b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe555bad89b04add4100bb6eb259a0194b1f30cb7d245dcd02418a7d65b6b2117c8903a3379747eacee069355060aefe3fd62798b03254d97a885af2d1d24f62
|
|
7
|
+
data.tar.gz: e77579335bff65754f3871272a7df43eca8b9728c7f1c314b4c39665520bdb8347c5a184868fe81f33d1518d8b66a914fa74a83d50f1fe9bc46057705f0d8da0
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../helpers/safe_review_app_name_contract"
|
|
4
|
+
|
|
5
|
+
module Renuo::Cli::Commands::ReviewApp
|
|
6
|
+
class Deploy < Actor
|
|
7
|
+
include ReviewAppHelper
|
|
8
|
+
include Renuo::Cli::Services
|
|
9
|
+
include Contracts::Core
|
|
10
|
+
|
|
11
|
+
command "review-app:deploy" do |c|
|
|
12
|
+
c.syntax = "renuo review-app:deploy [options]"
|
|
13
|
+
c.summary = "Copies the origin Deploio app (matching the origin pattern) and its services " \
|
|
14
|
+
"into a target review app for the current branch."
|
|
15
|
+
c.description = c.summary
|
|
16
|
+
c.option "-p", "--project <name>", String, "The name of the Deploio project"
|
|
17
|
+
c.option "-b", "--branch <name>", String, "The Git branch"
|
|
18
|
+
c.option "--origin-pattern <pattern>", String, "Name pattern identifying the origin app to copy from"
|
|
19
|
+
c.option "--start", "Start the app right away instead of waiting for its env variables to be configured"
|
|
20
|
+
c.option "--keep-envs <categories>", Array,
|
|
21
|
+
"Copied env categories (build or runtime) to keep instead of dropping " \
|
|
22
|
+
"(comma-separated): #{Renuo::Cli::Services::Deploio::CopyApp::ENV_CATEGORIES.join(",")}"
|
|
23
|
+
c.example "renuo review-app:deploy -p renuo-acme --branch feature/42-migrate-to-deploio --origin-pattern develop",
|
|
24
|
+
"Creates a review app tracking feature/42-migrate-to-deploio, copied from the develop app"
|
|
25
|
+
c.example "renuo review-app:deploy -p renuo-acme -b fix/1337-we-can-fix-everything111 --origin-pattern develop " \
|
|
26
|
+
"--keep-envs=#{Renuo::Cli::Services::Deploio::CopyApp::ENV_CATEGORIES.join(",")} --start",
|
|
27
|
+
"Creates a review app reusing all the origin's env variables and starts it right away"
|
|
28
|
+
c.action do |_args, options|
|
|
29
|
+
call(project: options.project, branch: options.branch, origin_pattern: options.origin_pattern,
|
|
30
|
+
start: options.start, keep_envs: options.keep_envs || [])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
input :project,
|
|
35
|
+
must: { be_given: { is: ->(value) { value }, message: ->(**) { "missing project name" } } }
|
|
36
|
+
input :branch,
|
|
37
|
+
must: { be_given: { is: ->(value) { value }, message: ->(**) { "missing branch name" } } }
|
|
38
|
+
input :origin_pattern,
|
|
39
|
+
must: { be_given: { is: ->(value) { value }, message: ->(**) { "missing origin pattern name" } } }
|
|
40
|
+
input :start, default: false
|
|
41
|
+
input :keep_envs,
|
|
42
|
+
default: [],
|
|
43
|
+
must: {
|
|
44
|
+
be_known_categories: {
|
|
45
|
+
is: ->(value) { (value - Deploio::CopyApp::ENV_CATEGORIES).empty? },
|
|
46
|
+
message: (lambda do |value:, **|
|
|
47
|
+
"unknown env categories: #{(value - Deploio::CopyApp::ENV_CATEGORIES).join(", ")}"
|
|
48
|
+
end)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
output :url
|
|
53
|
+
|
|
54
|
+
play :derive_target_app_name,
|
|
55
|
+
Deploio::InventorizeOriginResources,
|
|
56
|
+
:print_origin_resources,
|
|
57
|
+
Deploio::CopyApp
|
|
58
|
+
|
|
59
|
+
play Deploio::CreateDatabase,
|
|
60
|
+
Deploio::CopyDatabaseData,
|
|
61
|
+
if: ->(inventory) { inventory.origin_database_name }
|
|
62
|
+
|
|
63
|
+
play Deploio::CreateKvs,
|
|
64
|
+
if: ->(inventory) { inventory.origin_key_value_store_name }
|
|
65
|
+
|
|
66
|
+
play Deploio::CreateS3,
|
|
67
|
+
Deploio::CopyS3Data,
|
|
68
|
+
if: ->(inventory) { inventory.origin_bucket_name }
|
|
69
|
+
|
|
70
|
+
play :collect_target_envs,
|
|
71
|
+
Deploio::UpdateTargetAppEnv
|
|
72
|
+
|
|
73
|
+
play Deploio::StartTargetApp,
|
|
74
|
+
if: ->(result) { result.start }
|
|
75
|
+
|
|
76
|
+
play CmdTrail::Print,
|
|
77
|
+
:print_url
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
Contract Contracts::None => SafeReviewAppNameContract
|
|
82
|
+
def derive_target_app_name
|
|
83
|
+
result.target_app_name = review_app_name(branch).tap do |name|
|
|
84
|
+
abort "'#{name}' contains the origin pattern '#{origin_pattern}', aborting" if name.include?(origin_pattern)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def print_origin_resources
|
|
89
|
+
inventory_value = ->(key) { result.public_send(key) || "none" }
|
|
90
|
+
|
|
91
|
+
puts <<~INVENTORY
|
|
92
|
+
copying origin resources from project '#{result.project}':
|
|
93
|
+
app: #{result.origin_app_name}
|
|
94
|
+
database: #{inventory_value[:origin_database_name]}
|
|
95
|
+
database backup: #{inventory_value[:origin_backup_path]}
|
|
96
|
+
database backup bucket user: #{inventory_value[:origin_backup_bucket_user]}
|
|
97
|
+
key-value store: #{inventory_value[:origin_key_value_store_name]}
|
|
98
|
+
bucket: #{inventory_value[:origin_bucket_name]}
|
|
99
|
+
bucket user: #{inventory_value[:origin_bucket_user]}
|
|
100
|
+
INVENTORY
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def collect_target_envs
|
|
104
|
+
result.envs = [result.database_env, result.kvs_env, result.s3_env].compact
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def print_url
|
|
108
|
+
self.url = Deploio.fetch_resource("app", result.target_app_name, result.project)
|
|
109
|
+
.dig("status", "atProvider", "defaultURLs", 0)
|
|
110
|
+
puts "\nthe review app will run at #{url || "<no URL assigned yet>"}"
|
|
111
|
+
puts "start it with: renuo review-app:start --project #{project} --branch #{branch}" unless start
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../helpers/safe_review_app_name_contract"
|
|
4
|
+
|
|
5
|
+
module Renuo::Cli::Commands::ReviewApp
|
|
6
|
+
class Name
|
|
7
|
+
include ReviewAppHelper
|
|
8
|
+
include Contracts::Core
|
|
9
|
+
|
|
10
|
+
command "review-app:name" do |c|
|
|
11
|
+
c.syntax = "renuo review-app:name [options]"
|
|
12
|
+
c.summary = "Prints the review app name derived from the branch."
|
|
13
|
+
c.description = c.summary
|
|
14
|
+
c.option "-b", "--branch <name>", String, "The Git branch"
|
|
15
|
+
c.example "nctl update app $(renuo review-app:name -b add-s3-to-docs) -p renuo-acme --env KEY=value",
|
|
16
|
+
"Sets an env variable on the branch's review app"
|
|
17
|
+
c.action { |_args, options| new(options).run }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
puts @name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def initialize(options)
|
|
27
|
+
branch = options.branch
|
|
28
|
+
|
|
29
|
+
abort "missing branch name" unless branch
|
|
30
|
+
@name = derive_name(branch)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Contract String => SafeReviewAppNameContract
|
|
34
|
+
def derive_name(branch)
|
|
35
|
+
review_app_name(branch)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../helpers/safe_review_app_name_contract"
|
|
4
|
+
|
|
5
|
+
module Renuo::Cli::Commands::ReviewApp
|
|
6
|
+
class Purge
|
|
7
|
+
include ReviewAppHelper
|
|
8
|
+
include Contracts::Core
|
|
9
|
+
include Contracts::Invariants
|
|
10
|
+
|
|
11
|
+
RESOURCE_TYPES = %w[app postgresdatabase kvs bucket bucketuser].freeze
|
|
12
|
+
|
|
13
|
+
invariant(:name_pattern) { SafeReviewAppNameContract.valid?(@name_pattern) }
|
|
14
|
+
|
|
15
|
+
command "review-app:purge" do |c|
|
|
16
|
+
c.syntax = "renuo review-app:purge [options]"
|
|
17
|
+
c.summary = "Deletes all Deploio resources whose name contains the branch's review app name."
|
|
18
|
+
c.description = c.summary
|
|
19
|
+
c.option "-p", "--project <name>", String, "The name of the Deploio project"
|
|
20
|
+
c.option "-b", "--branch <name>", String, "The Git branch"
|
|
21
|
+
c.example "renuo review-app:purge -p renuo-acme -b feature/42-migrate-to-deploio",
|
|
22
|
+
"Deletes all review app resources named after the branch"
|
|
23
|
+
c.action { |_args, options| new(options).run }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
RESOURCE_TYPES.each { |type| purge_matching(type, @name_pattern) }
|
|
28
|
+
CmdTrail::Print.call
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def initialize(options)
|
|
34
|
+
@project = options.project
|
|
35
|
+
branch = options.branch
|
|
36
|
+
|
|
37
|
+
abort "missing project name" unless @project
|
|
38
|
+
abort "missing branch name" unless branch
|
|
39
|
+
derive_name_pattern(branch)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Contract String => SafeReviewAppNameContract
|
|
43
|
+
def derive_name_pattern(branch)
|
|
44
|
+
@name_pattern = review_app_name(branch)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Contract Contracts::Enum[*RESOURCE_TYPES], SafeReviewAppNameContract => Contracts::Any
|
|
48
|
+
def purge_matching(type, name_pattern)
|
|
49
|
+
names = matching_names(type, name_pattern)
|
|
50
|
+
return puts "no #{type} matching '#{name_pattern}' found, skipping" if names.empty?
|
|
51
|
+
|
|
52
|
+
names.each do |name|
|
|
53
|
+
puts "deleting #{type} #{name}..."
|
|
54
|
+
system! "nctl delete #{type} #{name} -p #{@project} --force --wait"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def matching_names(type, name_pattern)
|
|
59
|
+
nctl_resources("nctl get #{type} -p #{@project} -o yaml").filter_map do |resource|
|
|
60
|
+
next if resource.dig("metadata", "labels", "nine.ch/owned-by") == "nine"
|
|
61
|
+
|
|
62
|
+
name = resource.dig("metadata", "name")
|
|
63
|
+
name if name.include?(name_pattern)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Renuo::Cli::Commands::ReviewApp
|
|
4
|
+
class SetupSemaphoreci
|
|
5
|
+
DEPLOY_PIPELINE_TEMPLATE_FILE = File.join(__dir__, "../../templates/semaphore/review-app-deploy.yml.erb")
|
|
6
|
+
PURGE_PIPELINE_TEMPLATE_FILE = File.join(__dir__, "../../templates/semaphore/review-app-purge.yml.erb")
|
|
7
|
+
|
|
8
|
+
DEPLOY_PIPELINE_TARGET_FILE = ".semaphore/review-app-deploy.yml"
|
|
9
|
+
PURGE_PIPELINE_TARGET_FILE = ".semaphore/review-app-purge.yml"
|
|
10
|
+
|
|
11
|
+
attr_reader :project, :origin_pattern
|
|
12
|
+
|
|
13
|
+
command "review-app:setup-semaphoreci" do |c|
|
|
14
|
+
c.syntax = "renuo review-app:setup-semaphoreci [options]"
|
|
15
|
+
c.summary = "Generates the pipeline files to deploy and purge Deploio review apps."
|
|
16
|
+
c.description = c.summary
|
|
17
|
+
c.option "-p", "--project <name>", String, "The name of the Deploio project"
|
|
18
|
+
c.option "--origin-pattern <pattern>", String, "Name pattern identifying the origin app to copy from"
|
|
19
|
+
c.action { |_args, options| new(options).run }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run
|
|
23
|
+
FileUtils.mkdir_p File.dirname(DEPLOY_PIPELINE_TARGET_FILE)
|
|
24
|
+
write_or_warn DEPLOY_PIPELINE_TARGET_FILE,
|
|
25
|
+
render(DEPLOY_PIPELINE_TEMPLATE_FILE, { project:, origin_pattern:, secret_name: })
|
|
26
|
+
write_or_warn PURGE_PIPELINE_TARGET_FILE, render(PURGE_PIPELINE_TEMPLATE_FILE, { project:, secret_name: })
|
|
27
|
+
print_instructions
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def initialize(options)
|
|
33
|
+
@project = options.project || abort("missing project name")
|
|
34
|
+
@origin_pattern = options.origin_pattern || abort("missing origin pattern")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def secret_name
|
|
38
|
+
"nctl-#{project}-review-apps"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Deploio project names are prefixed with the name of their organization.
|
|
42
|
+
def organization
|
|
43
|
+
project.split("-").first
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def print_instructions
|
|
47
|
+
service_account_name = "review-apps"
|
|
48
|
+
say <<~INSTRUCTIONS
|
|
49
|
+
|
|
50
|
+
Create a service account restricted to the Deploio project #{project}:
|
|
51
|
+
|
|
52
|
+
nctl create asa #{service_account_name} --project #{project}
|
|
53
|
+
|
|
54
|
+
Register its credentials as the Semaphore secret #{secret_name}, used by both pipelines:
|
|
55
|
+
|
|
56
|
+
sem create secret #{secret_name} \\
|
|
57
|
+
-e NCTL_API_CLIENT_ID="$(nctl get asa #{service_account_name} --project #{project} --print-client-id)" \\
|
|
58
|
+
-e NCTL_API_CLIENT_SECRET="$(nctl get asa #{service_account_name} --project #{project} --print-client-secret)" \\
|
|
59
|
+
-e NCTL_ORGANIZATION=#{organization}
|
|
60
|
+
|
|
61
|
+
Add these promotions to .semaphore/semaphore.yml (no auto_promote, so they only run on click):
|
|
62
|
+
|
|
63
|
+
- name: review-app-deploy
|
|
64
|
+
pipeline_file: review-app-deploy.yml
|
|
65
|
+
|
|
66
|
+
- name: review-app-purge
|
|
67
|
+
pipeline_file: review-app-purge.yml
|
|
68
|
+
|
|
69
|
+
Both buttons appear on every branch workflow in Semaphore. Deploy and purge the review app
|
|
70
|
+
of a branch by clicking the promotion on that branch's workflow.
|
|
71
|
+
INSTRUCTIONS
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render(template_file, variables)
|
|
75
|
+
ERB.new(File.read(template_file)).result_with_hash(variables)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def write_or_warn(file_path, content)
|
|
79
|
+
if File.exist?(file_path)
|
|
80
|
+
warn("#{file_path} exists already. I will not overwrite it.")
|
|
81
|
+
else
|
|
82
|
+
File.write(file_path, content)
|
|
83
|
+
say("#{file_path} added.")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../helpers/safe_review_app_name_contract"
|
|
4
|
+
|
|
5
|
+
module Renuo::Cli::Commands::ReviewApp
|
|
6
|
+
class Start
|
|
7
|
+
include ReviewAppHelper
|
|
8
|
+
include Renuo::Cli::Services
|
|
9
|
+
include Contracts::Core
|
|
10
|
+
include Contracts::Invariants
|
|
11
|
+
|
|
12
|
+
invariant(:target_app_name) { SafeReviewAppNameContract.valid?(@target_app_name) }
|
|
13
|
+
|
|
14
|
+
command "review-app:start" do |c|
|
|
15
|
+
c.syntax = "renuo review-app:start [options]"
|
|
16
|
+
c.summary = "Starts the branch's review app once its env variables are configured."
|
|
17
|
+
c.description = c.summary
|
|
18
|
+
c.option "-p", "--project <name>", String, "The name of the Deploio project"
|
|
19
|
+
c.option "-b", "--branch <name>", String, "The Git branch"
|
|
20
|
+
c.example "renuo review-app:start -p renuo-acme -b feature/42-migrate-to-deploio",
|
|
21
|
+
"Builds and deploys the branch's review app"
|
|
22
|
+
c.action { |_args, options| new(options).run }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run
|
|
26
|
+
Deploio::StartTargetApp.call(project: @project, target_app_name: @target_app_name, branch: @branch)
|
|
27
|
+
CmdTrail::Print.call
|
|
28
|
+
print_url
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def print_url
|
|
34
|
+
url = Deploio.fetch_resource("app", @target_app_name, @project)
|
|
35
|
+
.dig("status", "atProvider", "defaultURLs", 0)
|
|
36
|
+
puts "\nthe review app will run at #{url || "<no URL assigned yet>"}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(options)
|
|
40
|
+
@project = options.project
|
|
41
|
+
@branch = options.branch
|
|
42
|
+
|
|
43
|
+
abort "missing project name" unless @project
|
|
44
|
+
abort "missing branch name" unless @branch
|
|
45
|
+
derive_target_app_name(@branch)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Contract String => SafeReviewAppNameContract
|
|
49
|
+
def derive_target_app_name(branch)
|
|
50
|
+
@target_app_name = review_app_name(branch)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Lets service actors state contracts.ruby-style contracts (anything with .valid?) as must: checks.
|
|
4
|
+
module ActorContracts
|
|
5
|
+
def satisfy(*contracts)
|
|
6
|
+
contracts.to_h do |contract|
|
|
7
|
+
raise ArgumentError, "#{contract.inspect} is not a contract (no .valid?)" unless contract.respond_to?(:valid?)
|
|
8
|
+
|
|
9
|
+
[:"satisfy_#{contract.name.demodulize.underscore}",
|
|
10
|
+
{ is: ->(value) { contract.valid?(value) },
|
|
11
|
+
message: ->(value:, **) { "#{value.inspect} is not #{contract}" } }]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Actor.extend(ActorContracts)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Collects every CLI command a run executes so Print can show how to reproduce it.
|
|
4
|
+
module CmdTrail
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def record(command, env = {})
|
|
8
|
+
entries << { command: command, env: env }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def entries
|
|
12
|
+
@entries ||= []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def clear
|
|
16
|
+
entries.clear
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Print < Actor
|
|
20
|
+
SENSITIVE_KEY_PATTERN = /KEY|SECRET|TOKEN|PASSWORD/i
|
|
21
|
+
URL_PASSWORD_PATTERN = %r{(://[^:/@\s]*:)[^@\s]+@}
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
return if CmdTrail.entries.empty?
|
|
25
|
+
|
|
26
|
+
puts "\nexecuted commands (rerun them to reproduce):"
|
|
27
|
+
CmdTrail.entries.each { |entry| puts " #{line(entry)}" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def line(entry)
|
|
33
|
+
[env_prefix(entry[:env]), redact(entry[:command])]
|
|
34
|
+
.reject(&:empty?).join(" ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def redact(command)
|
|
38
|
+
command.sub(/--sensitive-env .*/, "--sensitive-env [redacted]").gsub(URL_PASSWORD_PATTERN, '\\1[redacted]@')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def env_prefix(env)
|
|
42
|
+
env.map { |key, value| "#{key}=#{SENSITIVE_KEY_PATTERN.match?(key) ? "[redacted]" : value}" }.join(" ")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module ReviewAppHelper
|
|
6
|
+
HASH_LENGTH = 6
|
|
7
|
+
MAX_NUMBER_LENGTH = 10
|
|
8
|
+
NAME_PATTERN = /\Areview-app-(?:\d{1,#{MAX_NUMBER_LENGTH}}-)?\h{#{HASH_LENGTH}}\z/
|
|
9
|
+
MIN_PATTERN_LENGTH = 3
|
|
10
|
+
|
|
11
|
+
def review_app_name(branch)
|
|
12
|
+
number = branch[/\d+/]&.slice(0, MAX_NUMBER_LENGTH)
|
|
13
|
+
["review-app", number, Digest::SHA256.hexdigest(branch)[0, HASH_LENGTH]].compact.join("-")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# nctl prints a YAML stream, a YAML list, "[]" or a plain-text banner, depending on version and match count
|
|
17
|
+
def nctl_resources(command)
|
|
18
|
+
stdout, = system!(command, quiet: true)
|
|
19
|
+
return [] if stdout.lines.first.to_s.match?(/No ".+" found/)
|
|
20
|
+
|
|
21
|
+
resources = Psych.load_stream(stdout).flatten.compact
|
|
22
|
+
return resources if resources.all?(Hash)
|
|
23
|
+
|
|
24
|
+
abort "'#{command}' printed no nctl resources:\n#{stdout}"
|
|
25
|
+
rescue Psych::SyntaxError
|
|
26
|
+
abort "'#{command}' printed no nctl resources:\n#{stdout}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def system!(command, env = {}, quiet: false, abort_on_failure: true)
|
|
30
|
+
CmdTrail.record(command, env)
|
|
31
|
+
stdout, stderr, status = Open3.capture3(env, command)
|
|
32
|
+
puts stdout unless quiet || stdout.empty?
|
|
33
|
+
if !status.success? && abort_on_failure
|
|
34
|
+
abort "'#{command.split.first(2).join(" ")}' failed: #{stderr.presence || stdout}"
|
|
35
|
+
end
|
|
36
|
+
[stdout, stderr, status]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def aws_credentials(access_key, secret_key)
|
|
40
|
+
{
|
|
41
|
+
"AWS_ACCESS_KEY_ID" => access_key,
|
|
42
|
+
"AWS_SECRET_ACCESS_KEY" => secret_key,
|
|
43
|
+
"AWS_DEFAULT_REGION" => "us-east-1"
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "contracts"
|
|
4
|
+
|
|
5
|
+
# Module-wide contract: a name that may address review app resources on Deploio.
|
|
6
|
+
# Commands establish it as a postcondition of name derivation and keep it as a
|
|
7
|
+
# class invariant; the Deploio actors demand it as a precondition before touching
|
|
8
|
+
# any resource. A violation always means a bug, never bad user input.
|
|
9
|
+
class SafeReviewAppNameContract
|
|
10
|
+
def self.valid?(name)
|
|
11
|
+
name.is_a?(String) && name.match?(ReviewAppHelper::NAME_PATTERN)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.to_s
|
|
15
|
+
"a review app name matching #{ReviewAppHelper::NAME_PATTERN.inspect}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
class Renuo::Cli::Services::Deploio
|
|
6
|
+
class CopyApp < Actor
|
|
7
|
+
include ReviewAppHelper
|
|
8
|
+
include Renuo::Cli::Services
|
|
9
|
+
|
|
10
|
+
ENV_CATEGORIES = %w[build sensitive-build runtime sensitive-runtime].freeze
|
|
11
|
+
|
|
12
|
+
input :project
|
|
13
|
+
input :target_app_name, must: satisfy(SafeReviewAppNameContract)
|
|
14
|
+
input :origin_app_name
|
|
15
|
+
input :branch
|
|
16
|
+
input :keep_envs, default: []
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
if Deploio.resource_exists?("app", target_app_name, project)
|
|
20
|
+
puts "app #{target_app_name} already exists, skipping copy"
|
|
21
|
+
else
|
|
22
|
+
puts "copying app #{origin_app_name} to #{target_app_name}..."
|
|
23
|
+
system! "nctl copy app #{origin_app_name} -p #{project} --target-name #{target_app_name}"
|
|
24
|
+
reconfigure_copy
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def reconfigure_copy
|
|
31
|
+
Kernel.sleep(1) # Deploio usually needs a moment to finish reconciling the fresh copy
|
|
32
|
+
flags = [delete_flag("--delete-env", dropped_keys("config", "env", kind: "runtime")),
|
|
33
|
+
delete_flag("--delete-build-env", dropped_keys("buildEnv", kind: "build"))].compact
|
|
34
|
+
dropping = flags.empty? ? "" : " and dropping the copied env variables"
|
|
35
|
+
puts "pointing #{target_app_name} at branch #{branch}#{dropping}..."
|
|
36
|
+
system! ["nctl update app #{target_app_name} -p #{project}",
|
|
37
|
+
"--git-revision #{Shellwords.escape(branch)} --skip-repo-access-check",
|
|
38
|
+
*flags].join(" ")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def delete_flag(flag, keys)
|
|
42
|
+
"#{flag} #{Shellwords.escape(keys.join(";"))}" unless keys.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def dropped_keys(*path, kind:)
|
|
46
|
+
env = copied_spec.dig(*path) || []
|
|
47
|
+
env.reject { |variable| keep_envs.include?(variable["sensitive"] ? "sensitive-#{kind}" : kind) }
|
|
48
|
+
.map { |variable| variable["name"] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def copied_spec
|
|
52
|
+
@copied_spec ||= Deploio.fetch_resource("app", target_app_name, project).dig("spec", "forProvider") || {}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
class Renuo::Cli::Services::Deploio
|
|
6
|
+
class CopyDatabaseData < Actor
|
|
7
|
+
include ReviewAppHelper
|
|
8
|
+
|
|
9
|
+
BACKUP_FILE = File.join(Dir.tmpdir, "review-app-backup.sql.zst")
|
|
10
|
+
USER_TABLES_QUERY = "SELECT count(*) FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')"
|
|
11
|
+
|
|
12
|
+
input :target_app_name, must: satisfy(SafeReviewAppNameContract)
|
|
13
|
+
input :database_env
|
|
14
|
+
input :origin_backup_bucket
|
|
15
|
+
input :origin_backup_path
|
|
16
|
+
input :origin_backup_endpoint
|
|
17
|
+
input :origin_backup_access_key
|
|
18
|
+
input :origin_backup_secret_key
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
return warn "no origin database backup found, leaving #{target_app_name} empty" unless origin_backup_path
|
|
22
|
+
return puts "database #{target_app_name} already holds tables, skipping backup restore" unless database_empty?
|
|
23
|
+
|
|
24
|
+
puts "restoring #{origin_backup_path} into #{target_app_name}..."
|
|
25
|
+
download_backup
|
|
26
|
+
system! "zstd -dfq #{BACKUP_FILE}"
|
|
27
|
+
restore_backup
|
|
28
|
+
ensure
|
|
29
|
+
FileUtils.rm_f([BACKUP_FILE, BACKUP_FILE.delete_suffix(".zst")])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def download_backup
|
|
35
|
+
system! "aws s3 cp s3://#{origin_backup_bucket}/#{origin_backup_path} #{BACKUP_FILE} " \
|
|
36
|
+
"--endpoint-url https://#{origin_backup_endpoint}",
|
|
37
|
+
aws_credentials(origin_backup_access_key, origin_backup_secret_key)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def database_empty?
|
|
41
|
+
stdout, = system!("psql #{database_url} -tAc #{Shellwords.escape(USER_TABLES_QUERY)}", quiet: true)
|
|
42
|
+
Integer(stdout.strip).zero?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Despite their .sql.zst name, Deploio backups are pg_dump custom-format archives.
|
|
46
|
+
# A single transaction leaves the database empty on failure, so a rerun restores it again.
|
|
47
|
+
def restore_backup
|
|
48
|
+
system! "pg_restore --single-transaction --no-owner --no-privileges --no-comments " \
|
|
49
|
+
"-d #{database_url} " \
|
|
50
|
+
"#{BACKUP_FILE.delete_suffix(".zst")}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def database_url
|
|
54
|
+
Shellwords.escape(database_env.fetch("DATABASE_URL"))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "create_s3"
|
|
4
|
+
|
|
5
|
+
class Renuo::Cli::Services::Deploio
|
|
6
|
+
class CopyS3Data < Actor
|
|
7
|
+
include ReviewAppHelper
|
|
8
|
+
|
|
9
|
+
DATA_DIR = File.join(Dir.tmpdir, "review-app-bucket-data")
|
|
10
|
+
LISTING_ATTEMPTS = 24
|
|
11
|
+
LISTING_INTERVAL_IN_SECONDS = 5
|
|
12
|
+
|
|
13
|
+
input :target_app_name, must: satisfy(SafeReviewAppNameContract)
|
|
14
|
+
input :s3_env
|
|
15
|
+
input :origin_bucket_name
|
|
16
|
+
input :origin_bucket_endpoint
|
|
17
|
+
input :origin_bucket_access_key
|
|
18
|
+
input :origin_bucket_secret_key
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
return puts "bucket #{target_app_name} already holds objects, skipping data copy" unless bucket_empty?
|
|
22
|
+
|
|
23
|
+
puts "copying bucket data from #{origin_bucket_name} into #{target_app_name}..."
|
|
24
|
+
download_data
|
|
25
|
+
upload_data
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def download_data
|
|
31
|
+
system! "aws s3 sync s3://#{origin_bucket_name} #{DATA_DIR} --endpoint-url https://#{origin_bucket_endpoint}",
|
|
32
|
+
aws_credentials(origin_bucket_access_key, origin_bucket_secret_key)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# aws exits non-zero on some empty listings, so only stderr tells a failure apart.
|
|
36
|
+
def bucket_empty?
|
|
37
|
+
stdout, stderr = list_target_bucket
|
|
38
|
+
abort "'aws s3' failed: #{stderr}" unless stderr.strip.empty?
|
|
39
|
+
stdout.strip.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Deploio reports a fresh bucket ready seconds before its permissions grant access.
|
|
43
|
+
def list_target_bucket
|
|
44
|
+
(1..LISTING_ATTEMPTS).each do |attempt|
|
|
45
|
+
stdout, stderr, = system!("aws s3 ls s3://#{target_app_name} --endpoint-url #{target_endpoint}",
|
|
46
|
+
target_credentials, quiet: true, abort_on_failure: false)
|
|
47
|
+
return [stdout, stderr] if attempt == LISTING_ATTEMPTS || stderr.exclude?("AccessDenied")
|
|
48
|
+
|
|
49
|
+
puts "waiting for access to bucket #{target_app_name}..."
|
|
50
|
+
Kernel.sleep(LISTING_INTERVAL_IN_SECONDS)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def upload_data
|
|
55
|
+
system! "aws s3 sync #{DATA_DIR} s3://#{target_app_name} --endpoint-url #{target_endpoint}", target_credentials
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def target_endpoint
|
|
59
|
+
s3_env.fetch(target_schema[:endpoint])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def target_credentials
|
|
63
|
+
aws_credentials(s3_env.fetch(target_schema[:access_key]), s3_env.fetch(target_schema[:secret_key]))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def target_schema
|
|
67
|
+
CreateS3::ENV_SCHEMAS_IN_THE_WILD.find { |candidate| s3_env.key?(candidate[:secret_key]) }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
class Renuo::Cli::Services::Deploio
|
|
6
|
+
class CreateDatabase < Actor
|
|
7
|
+
include ReviewAppHelper
|
|
8
|
+
include Renuo::Cli::Services
|
|
9
|
+
|
|
10
|
+
input :project
|
|
11
|
+
input :target_app_name, must: satisfy(SafeReviewAppNameContract)
|
|
12
|
+
input :origin_database_location, allow_nil: true, default: nil
|
|
13
|
+
input :origin_database_version, allow_nil: true, default: nil
|
|
14
|
+
input :origin_database_collation, allow_nil: true, default: nil
|
|
15
|
+
|
|
16
|
+
output :database_env
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
if Deploio.resource_exists?("postgresdatabase", target_app_name, project)
|
|
20
|
+
puts "database #{target_app_name} already exists, skipping creation"
|
|
21
|
+
else
|
|
22
|
+
create_database
|
|
23
|
+
end
|
|
24
|
+
self.database_env = { "DATABASE_URL" => database_url }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def create_database
|
|
30
|
+
puts "creating database #{target_app_name}..."
|
|
31
|
+
system! ["nctl create postgresdatabase #{target_app_name} -p #{project}",
|
|
32
|
+
*origin_flags,
|
|
33
|
+
"--backup-schedule=disabled --wait"].join(" ")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def origin_flags
|
|
37
|
+
{ "--location" => origin_database_location,
|
|
38
|
+
"--postgres-database-version" => origin_database_version,
|
|
39
|
+
"--collation" => origin_database_collation }
|
|
40
|
+
.filter_map { |flag, value| "#{flag} #{Shellwords.escape(value)}" if value }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def database_url
|
|
44
|
+
stdout, = system!("nctl get postgresdatabase #{target_app_name} -p #{project} --print-connection-string",
|
|
45
|
+
quiet: true)
|
|
46
|
+
stdout.strip
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
class Renuo::Cli::Services::Deploio
|
|
6
|
+
class CreateKvs < Actor
|
|
7
|
+
include ReviewAppHelper
|
|
8
|
+
include Renuo::Cli::Services
|
|
9
|
+
|
|
10
|
+
input :project
|
|
11
|
+
input :target_app_name, must: satisfy(SafeReviewAppNameContract)
|
|
12
|
+
input :origin_key_value_store_location, allow_nil: true, default: nil
|
|
13
|
+
|
|
14
|
+
output :kvs_env
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
if Deploio.resource_exists?("kvs", target_app_name, project)
|
|
18
|
+
puts "key-value store #{target_app_name} already exists, skipping creation"
|
|
19
|
+
else
|
|
20
|
+
create_kvs
|
|
21
|
+
end
|
|
22
|
+
self.kvs_env = { "REDIS_URL" => redis_url }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def create_kvs
|
|
28
|
+
system! ["nctl create kvs #{target_app_name} -p #{project}", location_flag, "--wait"].compact.join(" ")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def location_flag
|
|
32
|
+
"--location #{Shellwords.escape(origin_key_value_store_location)}" if origin_key_value_store_location
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def redis_url
|
|
36
|
+
token, = system!("nctl get kvs #{target_app_name} -p #{project} --print-token", quiet: true)
|
|
37
|
+
fqdn = Deploio.fetch_resource("kvs", target_app_name, project).dig("status", "atProvider", "fqdn")
|
|
38
|
+
"rediss://:#{token.strip}@#{fqdn}:6379"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Renuo::Cli::Services::Deploio
|
|
4
|
+
class CreateS3 < Actor
|
|
5
|
+
include ReviewAppHelper
|
|
6
|
+
include Renuo::Cli::Services
|
|
7
|
+
|
|
8
|
+
DEFAULT_BUCKET_LOCATION = "nine-es34"
|
|
9
|
+
ENV_SCHEMAS_IN_THE_WILD = [
|
|
10
|
+
{
|
|
11
|
+
name: "DEPLOIO_BUCKET_NAME",
|
|
12
|
+
endpoint: "DEPLOIO_BUCKET_ENDPOINT",
|
|
13
|
+
access_key: "DEPLOIO_BUCKET_ACCESS_KEY",
|
|
14
|
+
secret_key: "DEPLOIO_BUCKET_SECRET_KEY"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "DEPLOIO_BUCKET",
|
|
18
|
+
endpoint: "DEPLOIO_ENDPOINT",
|
|
19
|
+
access_key: "DEPLOIO_ACCESS_KEY",
|
|
20
|
+
secret_key: "DEPLOIO_SECRET_KEY"
|
|
21
|
+
}
|
|
22
|
+
].freeze
|
|
23
|
+
SENSITIVE_ENV_KEYS = ENV_SCHEMAS_IN_THE_WILD.flat_map { |schema| schema.values_at(:access_key, :secret_key) }.freeze
|
|
24
|
+
|
|
25
|
+
input :project
|
|
26
|
+
input :target_app_name, must: satisfy(SafeReviewAppNameContract)
|
|
27
|
+
input :template, default: {}
|
|
28
|
+
|
|
29
|
+
output :s3_env
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
location = template[:bucket_location] || DEFAULT_BUCKET_LOCATION
|
|
33
|
+
create_bucket_user(location)
|
|
34
|
+
create_bucket(location)
|
|
35
|
+
self.s3_env = bucket_env
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def app_env?(key)
|
|
41
|
+
(template[:app_env] || []).any? { |variable| variable["name"] == key }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def create_bucket_user(location)
|
|
45
|
+
return if Deploio.resource_exists?("bucketuser", target_app_name, project)
|
|
46
|
+
|
|
47
|
+
system! "nctl create bucketuser #{target_app_name} -p #{project} --location #{location} --wait"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create_bucket(location)
|
|
51
|
+
if Deploio.resource_exists?("bucket", target_app_name, project)
|
|
52
|
+
return puts "bucket #{target_app_name} already exists, skipping creation"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
system! "nctl create bucket #{target_app_name} -p #{project} --location #{location} " \
|
|
56
|
+
"--permissions \"reader=#{target_app_name};writer=#{target_app_name}\" --wait"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def bucket_env
|
|
60
|
+
schema = env_schema
|
|
61
|
+
{
|
|
62
|
+
schema[:name] => target_app_name,
|
|
63
|
+
schema[:endpoint] => "https://#{bucket_endpoint}",
|
|
64
|
+
schema[:access_key] => bucket_credential("--print-access-key"),
|
|
65
|
+
schema[:secret_key] => bucket_credential("--print-secret-key")
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def bucket_endpoint
|
|
70
|
+
Deploio.fetch_resource("bucket", target_app_name, project).dig("status", "atProvider", "endpoint")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def bucket_credential(flag)
|
|
74
|
+
stdout, = system!("nctl get bucketuser #{target_app_name} -p #{project} #{flag}", quiet: true)
|
|
75
|
+
stdout.strip
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def env_schema
|
|
79
|
+
ENV_SCHEMAS_IN_THE_WILD.find { |schema| app_env?(schema[:secret_key]) } || ENV_SCHEMAS_IN_THE_WILD.first
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Renuo::Cli::Services::Deploio
|
|
4
|
+
class InventorizeOriginResources < Actor # rubocop:disable Metrics/ClassLength
|
|
5
|
+
include ReviewAppHelper
|
|
6
|
+
|
|
7
|
+
input :project
|
|
8
|
+
input :origin_pattern,
|
|
9
|
+
must: {
|
|
10
|
+
be_specific_enough: {
|
|
11
|
+
is: ->(pattern) { pattern.to_s.length >= ReviewAppHelper::MIN_PATTERN_LENGTH },
|
|
12
|
+
message: (lambda do |value:, **|
|
|
13
|
+
"origin pattern '#{value}' must be at least #{ReviewAppHelper::MIN_PATTERN_LENGTH} " \
|
|
14
|
+
"characters long, aborting"
|
|
15
|
+
end)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
output :origin_app_name
|
|
20
|
+
output :origin_database_name
|
|
21
|
+
output :origin_database_location
|
|
22
|
+
output :origin_database_version
|
|
23
|
+
output :origin_database_collation
|
|
24
|
+
output :origin_backup_bucket
|
|
25
|
+
output :origin_backup_path
|
|
26
|
+
output :origin_backup_endpoint
|
|
27
|
+
output :origin_backup_bucket_user
|
|
28
|
+
output :origin_backup_access_key
|
|
29
|
+
output :origin_backup_secret_key
|
|
30
|
+
output :origin_key_value_store_name
|
|
31
|
+
output :origin_key_value_store_location
|
|
32
|
+
output :origin_bucket_name
|
|
33
|
+
output :origin_bucket_endpoint
|
|
34
|
+
output :origin_bucket_user
|
|
35
|
+
output :origin_bucket_access_key
|
|
36
|
+
output :origin_bucket_secret_key
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
inventorize_app
|
|
40
|
+
inventorize_database
|
|
41
|
+
inventorize_backup
|
|
42
|
+
inventorize_key_value_store
|
|
43
|
+
inventorize_bucket
|
|
44
|
+
ensure_required_tools
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def inventorize_app
|
|
50
|
+
self.origin_app_name = origin_app_name!
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def inventorize_database
|
|
54
|
+
database = origin_resource("PostgresDatabase")
|
|
55
|
+
self.origin_database_name = database&.dig("metadata", "name")
|
|
56
|
+
self.origin_database_location, self.origin_database_version, self.origin_database_collation =
|
|
57
|
+
%w[location version collation].map { |key| database&.dig("spec", "forProvider", key)&.to_s }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def inventorize_backup
|
|
61
|
+
self.origin_backup_bucket = backup_bucket_name
|
|
62
|
+
self.origin_backup_path = latest_backup_path
|
|
63
|
+
backup_bucket = bucket_resource(origin_backup_bucket)
|
|
64
|
+
self.origin_backup_endpoint = backup_bucket&.dig("status", "atProvider", "endpoint")
|
|
65
|
+
self.origin_backup_bucket_user = bucket_user(backup_bucket)
|
|
66
|
+
self.origin_backup_access_key, self.origin_backup_secret_key =
|
|
67
|
+
bucketuser_credentials(origin_backup_bucket_user)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def bucket_user(bucket)
|
|
71
|
+
return nil unless bucket
|
|
72
|
+
|
|
73
|
+
bucket_reader_user(bucket) || abort("no bucket user for '#{bucket.dig("metadata", "name")}' in #{project}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Bucket users are not necessarily named like their bucket; the bucket's permissions reference them.
|
|
77
|
+
def bucket_reader_user(bucket)
|
|
78
|
+
permissions = bucket.dig("spec", "forProvider", "permissions") || []
|
|
79
|
+
permission = permissions.find { |candidate| candidate["role"] == "reader" } || permissions.first
|
|
80
|
+
permission&.dig("userRefs", 0, "name")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Fetched eagerly (even for re-deploys that copy no data) so no origin-side nctl calls remain after inventory.
|
|
84
|
+
def bucketuser_credentials(user)
|
|
85
|
+
return [nil, nil] unless user
|
|
86
|
+
|
|
87
|
+
%w[--print-access-key --print-secret-key].map do |flag|
|
|
88
|
+
stdout, = system!("nctl get bucketuser #{user} -p #{project} #{flag}", quiet: true)
|
|
89
|
+
stdout.strip
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def inventorize_key_value_store
|
|
94
|
+
key_value_store = origin_resource("KeyValueStore")
|
|
95
|
+
self.origin_key_value_store_name = key_value_store&.dig("metadata", "name")
|
|
96
|
+
self.origin_key_value_store_location = key_value_store&.dig("spec", "forProvider", "location")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def inventorize_bucket
|
|
100
|
+
bucket = origin_resource("Bucket")
|
|
101
|
+
self.origin_bucket_name = bucket&.dig("metadata", "name")
|
|
102
|
+
self.origin_bucket_endpoint = bucket&.dig("status", "atProvider", "endpoint")
|
|
103
|
+
self.origin_bucket_user = bucket_user(bucket)
|
|
104
|
+
self.origin_bucket_access_key, self.origin_bucket_secret_key = bucketuser_credentials(origin_bucket_user)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def ensure_required_tools
|
|
108
|
+
missing = required_tools.reject { |tool| installed?(tool) }
|
|
109
|
+
abort "missing #{missing.join(", ")} to copy the origin data, install them and rerun" if missing.any?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def required_tools
|
|
113
|
+
tools = []
|
|
114
|
+
tools += %w[aws pg_restore psql zstd] if origin_backup_path
|
|
115
|
+
tools += %w[aws] if origin_bucket_name
|
|
116
|
+
tools.uniq
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def installed?(tool)
|
|
120
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, tool)) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def resources
|
|
124
|
+
# nctl hides the whole listing behind its no-resources banner when the project holds
|
|
125
|
+
# no nine-owned resources; the plain listing still contains everything else.
|
|
126
|
+
@resources ||= nctl_resources("nctl get all -p #{project} --include-nine-resources -o yaml").presence ||
|
|
127
|
+
nctl_resources("nctl get all -p #{project} -o yaml")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def origin_resources
|
|
131
|
+
@origin_resources ||= resources.select do |resource|
|
|
132
|
+
resource.dig("metadata", "labels", "nine.ch/owned-by") != "nine" &&
|
|
133
|
+
resource.dig("metadata", "name").include?(origin_pattern)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def origin_app_name!
|
|
138
|
+
abort "project '#{project}' holds no resources, is its name right?" if resources.empty?
|
|
139
|
+
|
|
140
|
+
origin_resource("Application")&.dig("metadata", "name") ||
|
|
141
|
+
abort("no origin app (name containing '#{origin_pattern}') found in #{project}")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def origin_resource(kind)
|
|
145
|
+
origin_resources.find { |resource| resource["kind"] == kind }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def resources_of(kind)
|
|
149
|
+
resources.select { |resource| resource["kind"] == kind }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def backup_bucket_name
|
|
153
|
+
return nil unless origin_database_name
|
|
154
|
+
|
|
155
|
+
resources_of("DatabaseBackupSchedule")
|
|
156
|
+
.find { |schedule| schedule.dig("spec", "forProvider", "source", "name") == origin_database_name }
|
|
157
|
+
&.dig("status", "atProvider", "targetBucket", "name")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def latest_backup_path
|
|
161
|
+
return nil unless origin_backup_bucket
|
|
162
|
+
|
|
163
|
+
resources_of("DatabaseBackup")
|
|
164
|
+
.select { |backup| backup.dig("spec", "forProvider", "bucket", "name") == origin_backup_bucket }
|
|
165
|
+
.select { |backup| backup.dig("status", "atProvider", "state") == "succeeded" }
|
|
166
|
+
.max_by { |backup| backup.dig("status", "atProvider", "end").to_s }
|
|
167
|
+
&.dig("status", "atProvider", "path")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def bucket_resource(name)
|
|
171
|
+
name && resources_of("Bucket").find { |bucket| bucket.dig("metadata", "name") == name }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
class Renuo::Cli::Services::Deploio
|
|
6
|
+
class StartTargetApp < Actor
|
|
7
|
+
include ReviewAppHelper
|
|
8
|
+
|
|
9
|
+
input :project
|
|
10
|
+
input :target_app_name, must: satisfy(SafeReviewAppNameContract)
|
|
11
|
+
input :branch
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
puts "starting app #{target_app_name}..."
|
|
15
|
+
system! "nctl update app #{target_app_name} -p #{project} --git-revision #{Shellwords.escape(branch)} " \
|
|
16
|
+
"--skip-repo-access-check --no-pause"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require_relative "create_s3"
|
|
5
|
+
|
|
6
|
+
class Renuo::Cli::Services::Deploio
|
|
7
|
+
class UpdateTargetAppEnv < Actor
|
|
8
|
+
include ReviewAppHelper
|
|
9
|
+
|
|
10
|
+
SENSITIVE_ENV_KEYS = (%w[DATABASE_URL REDIS_URL] + CreateS3::SENSITIVE_ENV_KEYS).freeze
|
|
11
|
+
|
|
12
|
+
input :project
|
|
13
|
+
input :target_app_name, must: satisfy(SafeReviewAppNameContract)
|
|
14
|
+
input :envs
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
return if env.empty?
|
|
18
|
+
|
|
19
|
+
puts "updating env of app #{target_app_name}..."
|
|
20
|
+
system! ["nctl update app #{target_app_name} -p #{project}",
|
|
21
|
+
env_flag("--env", env.except(*SENSITIVE_ENV_KEYS)),
|
|
22
|
+
env_flag("--sensitive-env", env.slice(*SENSITIVE_ENV_KEYS))].compact.join(" ")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def env
|
|
28
|
+
@env ||= envs.reduce({}, :merge)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def env_flag(flag, variables)
|
|
32
|
+
return if variables.empty?
|
|
33
|
+
|
|
34
|
+
"#{flag} #{Shellwords.escape(variables.map { |key, value| "#{key}=#{value}" }.join(";"))}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -3,7 +3,21 @@
|
|
|
3
3
|
require "shellwords"
|
|
4
4
|
|
|
5
5
|
class Renuo::Cli::Services::Deploio
|
|
6
|
+
extend ReviewAppHelper
|
|
7
|
+
|
|
6
8
|
class << self
|
|
9
|
+
# A named nctl get that misses searches every project of the organization,
|
|
10
|
+
# which project-scoped service accounts are forbidden to do.
|
|
11
|
+
def resource_exists?(type, name, project)
|
|
12
|
+
nctl_resources("nctl get #{type} -p #{project} -o yaml").any? do |resource|
|
|
13
|
+
resource.dig("metadata", "name") == name
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch_resource(type, name, project)
|
|
18
|
+
nctl_resources("nctl get #{type} #{name} -p #{project} -o yaml").first
|
|
19
|
+
end
|
|
20
|
+
|
|
7
21
|
def fetch_apps
|
|
8
22
|
active_org, available_orgs = fetch_whoami_orgs
|
|
9
23
|
|
|
@@ -33,9 +47,7 @@ class Renuo::Cli::Services::Deploio
|
|
|
33
47
|
end
|
|
34
48
|
|
|
35
49
|
def fetch_vms
|
|
36
|
-
|
|
37
|
-
stdout, stderr, status = Open3.capture3 fetch_vms_cmd
|
|
38
|
-
raise "Error fetching Deploio app list: #{stderr}" unless status.success?
|
|
50
|
+
stdout, = run_nctl_command(%(nctl get cloudvm -A -o yaml))
|
|
39
51
|
|
|
40
52
|
YAML.load_stream(stdout).map do |app|
|
|
41
53
|
{
|
|
@@ -61,7 +73,7 @@ class Renuo::Cli::Services::Deploio
|
|
|
61
73
|
end
|
|
62
74
|
|
|
63
75
|
def run_nctl_command(cmd)
|
|
64
|
-
stdout, stderr, status =
|
|
76
|
+
stdout, stderr, status = system!(cmd, quiet: true, abort_on_failure: false)
|
|
65
77
|
raise "Error fetching Deploio app list: #{stderr}" unless status.success?
|
|
66
78
|
|
|
67
79
|
[stdout, stderr, status]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
version: v1.0
|
|
2
|
+
name: review-app-deploy
|
|
3
|
+
|
|
4
|
+
agent:
|
|
5
|
+
machine:
|
|
6
|
+
type: e1-standard-2
|
|
7
|
+
os_image: ubuntu2404
|
|
8
|
+
|
|
9
|
+
blocks:
|
|
10
|
+
- name: review-app-deploy
|
|
11
|
+
execution_time_limit:
|
|
12
|
+
minutes: 30
|
|
13
|
+
task:
|
|
14
|
+
secrets:
|
|
15
|
+
- name: <%= secret_name %>
|
|
16
|
+
jobs:
|
|
17
|
+
- name: deploying
|
|
18
|
+
commands:
|
|
19
|
+
- gem i -q renuo-cli
|
|
20
|
+
- renuo ci setup
|
|
21
|
+
# pg_restore rejects backups dumped by a newer PostgreSQL, so match or exceed the origin database's version.
|
|
22
|
+
- sudo apt-get install -qq postgresql-common
|
|
23
|
+
- sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
|
24
|
+
- sudo apt-get install -qq postgresql-client-18
|
|
25
|
+
- export PATH=/usr/lib/postgresql/18/bin:$PATH
|
|
26
|
+
- renuo review-app:deploy --project <%= project %> --branch $SEMAPHORE_GIT_WORKING_BRANCH --origin-pattern <%= origin_pattern %>
|
|
27
|
+
# Make sure your review apps have the right environment variables. We don't copy from origin.
|
|
28
|
+
#
|
|
29
|
+
# -| nctl update app $(renuo review-app:name --branch $SEMAPHORE_GIT_WORKING_BRANCH) --project <%= project %> \
|
|
30
|
+
# --env "KEY=value" --sensitive-env "SECRET=$FROM_SEMAPHORE_SECRET" \
|
|
31
|
+
# --build-env "KEY=value" --sensitive-build-env "KEY=value"
|
|
32
|
+
#
|
|
33
|
+
# To find out which env variables a previously existing app uses, inspect its spec:
|
|
34
|
+
#
|
|
35
|
+
# - nctl get app existing-app --project <%= project %> -o yaml
|
|
36
|
+
#
|
|
37
|
+
# The runtime env is at .spec.forProvider.config.env, the build env at .spec.forProvider.buildEnv.
|
|
38
|
+
#
|
|
39
|
+
# If the origin's env variables are safe to reuse on review apps, you can replace this whole
|
|
40
|
+
# block (deploy, env setup, and start) with a single command:
|
|
41
|
+
#
|
|
42
|
+
# - renuo review-app:deploy --project <%= project %> --branch $SEMAPHORE_GIT_WORKING_BRANCH --origin-pattern <%= origin_pattern %> --keep-envs=build,sensitive-build,runtime,sensitive-runtime --start
|
|
43
|
+
- renuo review-app:start --project <%= project %> --branch $SEMAPHORE_GIT_WORKING_BRANCH
|
|
44
|
+
- renuo ci check-deploio-status $(renuo review-app:name --branch $SEMAPHORE_GIT_WORKING_BRANCH) --project <%= project %>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
version: v1.0
|
|
2
|
+
name: review-app-purge
|
|
3
|
+
|
|
4
|
+
agent:
|
|
5
|
+
machine:
|
|
6
|
+
type: e1-standard-2
|
|
7
|
+
os_image: ubuntu2404
|
|
8
|
+
|
|
9
|
+
blocks:
|
|
10
|
+
- name: review-app-purge
|
|
11
|
+
execution_time_limit:
|
|
12
|
+
minutes: 15
|
|
13
|
+
task:
|
|
14
|
+
secrets:
|
|
15
|
+
- name: <%= secret_name %>
|
|
16
|
+
jobs:
|
|
17
|
+
- name: purging
|
|
18
|
+
commands:
|
|
19
|
+
- gem i -q renuo-cli
|
|
20
|
+
- renuo ci setup
|
|
21
|
+
- renuo review-app:purge --project <%= project %> --branch $SEMAPHORE_GIT_WORKING_BRANCH
|
data/lib/renuo/cli/version.rb
CHANGED
data/lib/renuo/cli.rb
CHANGED
|
@@ -10,11 +10,21 @@ require "net/http"
|
|
|
10
10
|
require "open3"
|
|
11
11
|
require "psych"
|
|
12
12
|
require "securerandom"
|
|
13
|
+
require "service_actor"
|
|
13
14
|
require "tmpdir"
|
|
14
15
|
require "uri"
|
|
15
16
|
require "renuo/cli/version"
|
|
16
17
|
|
|
18
|
+
module Renuo
|
|
19
|
+
class Cli
|
|
20
|
+
module Commands; end
|
|
21
|
+
module Services; end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
17
25
|
Dir[File.expand_path("cli/helpers/*.rb", __dir__)].each { |f| require f }
|
|
26
|
+
require_relative "cli/services/deploio"
|
|
27
|
+
Dir[File.expand_path("cli/services/deploio/*.rb", __dir__)].each { |f| require f }
|
|
18
28
|
|
|
19
29
|
def agree(question)
|
|
20
30
|
question += " [y/n]" if question
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: renuo-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.
|
|
4
|
+
version: 4.28.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Renuo AG
|
|
@@ -51,6 +51,34 @@ dependencies:
|
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
53
|
version: '5.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: contracts
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.17'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.17'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: service_actor
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.9'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.9'
|
|
54
82
|
description: The Renuo CLI automates some commonly used workflows by providing a command
|
|
55
83
|
line interface.
|
|
56
84
|
email:
|
|
@@ -89,25 +117,45 @@ files:
|
|
|
89
117
|
- lib/renuo/cli/commands/heroku_users.rb
|
|
90
118
|
- lib/renuo/cli/commands/list_large_git_files.rb
|
|
91
119
|
- lib/renuo/cli/commands/release.rb
|
|
120
|
+
- lib/renuo/cli/commands/review_app/deploy.rb
|
|
121
|
+
- lib/renuo/cli/commands/review_app/name.rb
|
|
122
|
+
- lib/renuo/cli/commands/review_app/purge.rb
|
|
123
|
+
- lib/renuo/cli/commands/review_app/setup_semaphoreci.rb
|
|
124
|
+
- lib/renuo/cli/commands/review_app/start.rb
|
|
92
125
|
- lib/renuo/cli/commands/setup_autocompletion.rb
|
|
93
126
|
- lib/renuo/cli/commands/setup_uptimerobot.rb
|
|
94
127
|
- lib/renuo/cli/commands/upgrade_laptop.rb
|
|
95
128
|
- lib/renuo/cli/credentials.yml.enc
|
|
129
|
+
- lib/renuo/cli/helpers/actor_contracts.rb
|
|
130
|
+
- lib/renuo/cli/helpers/cmd_trail.rb
|
|
96
131
|
- lib/renuo/cli/helpers/command_helper.rb
|
|
97
132
|
- lib/renuo/cli/helpers/commander_introspector.rb
|
|
98
133
|
- lib/renuo/cli/helpers/credentials_helper.rb
|
|
99
134
|
- lib/renuo/cli/helpers/environments.rb
|
|
100
135
|
- lib/renuo/cli/helpers/renuo_version.rb
|
|
136
|
+
- lib/renuo/cli/helpers/review_app_helper.rb
|
|
137
|
+
- lib/renuo/cli/helpers/safe_review_app_name_contract.rb
|
|
101
138
|
- lib/renuo/cli/helpers/zsh_completion_script.rb
|
|
102
139
|
- lib/renuo/cli/services/cache.rb
|
|
103
140
|
- lib/renuo/cli/services/cloudfront_config_service.rb
|
|
104
141
|
- lib/renuo/cli/services/deploio.rb
|
|
142
|
+
- lib/renuo/cli/services/deploio/copy_app.rb
|
|
143
|
+
- lib/renuo/cli/services/deploio/copy_database_data.rb
|
|
144
|
+
- lib/renuo/cli/services/deploio/copy_s3_data.rb
|
|
145
|
+
- lib/renuo/cli/services/deploio/create_database.rb
|
|
146
|
+
- lib/renuo/cli/services/deploio/create_kvs.rb
|
|
147
|
+
- lib/renuo/cli/services/deploio/create_s3.rb
|
|
148
|
+
- lib/renuo/cli/services/deploio/inventorize_origin_resources.rb
|
|
149
|
+
- lib/renuo/cli/services/deploio/start_target_app.rb
|
|
150
|
+
- lib/renuo/cli/services/deploio/update_target_app_env.rb
|
|
105
151
|
- lib/renuo/cli/services/heroku.rb
|
|
106
152
|
- lib/renuo/cli/services/hetzner.rb
|
|
107
153
|
- lib/renuo/cli/services/renuo_cli_config.rb
|
|
108
154
|
- lib/renuo/cli/templates/semaphore/bin/cache_restore.erb
|
|
109
155
|
- lib/renuo/cli/templates/semaphore/bin/cache_store.erb
|
|
110
156
|
- lib/renuo/cli/templates/semaphore/notification.yml.erb
|
|
157
|
+
- lib/renuo/cli/templates/semaphore/review-app-deploy.yml.erb
|
|
158
|
+
- lib/renuo/cli/templates/semaphore/review-app-purge.yml.erb
|
|
111
159
|
- lib/renuo/cli/templates/semaphore/semaphore-deploy.yml.erb
|
|
112
160
|
- lib/renuo/cli/templates/semaphore/semaphore.yml.erb
|
|
113
161
|
- lib/renuo/cli/templates/slidev/README.md.erb
|