renuo-cli 4.26.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/ci/check_deploio_status.rb +3 -3
- 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/templates/semaphore/semaphore.yml.erb +1 -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
|
|
@@ -18,7 +18,8 @@ module Renuo::Cli::Commands::Ci
|
|
|
18
18
|
|
|
19
19
|
def run
|
|
20
20
|
puts "[RENUO-CLI] (1/2) Checking build status for revision #{@revision}..."
|
|
21
|
-
|
|
21
|
+
puts "[RENUO-CLI] Waiting for build to start..."
|
|
22
|
+
succeeded = poll { build } && with_streamed_build_logs { poll_build }
|
|
22
23
|
abort "[RENUO-CLI] build check timed out after #{TIMEOUT_IN_SECONDS} seconds" unless succeeded
|
|
23
24
|
|
|
24
25
|
puts "[RENUO-CLI] (2/2) Checking release status for build #{build_name}..."
|
|
@@ -51,7 +52,7 @@ module Renuo::Cli::Commands::Ci
|
|
|
51
52
|
|
|
52
53
|
def with_streamed_build_logs
|
|
53
54
|
pid = Process.spawn(
|
|
54
|
-
"nctl logs
|
|
55
|
+
"nctl logs build #{build_name} -a #{@app} -p #{@project} -f --lines=100 --no-labels",
|
|
55
56
|
out: $stdout, err: $stdout
|
|
56
57
|
)
|
|
57
58
|
yield
|
|
@@ -119,7 +120,6 @@ module Renuo::Cli::Commands::Ci
|
|
|
119
120
|
|
|
120
121
|
def poll_build
|
|
121
122
|
poll do
|
|
122
|
-
next puts("[RENUO-CLI] no matching build found, waiting...") unless build
|
|
123
123
|
next true if succeeded?("build", build_name, build_status)
|
|
124
124
|
|
|
125
125
|
@build = nil
|
|
@@ -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
|