renuo-cli 4.27.0 → 4.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b4a37d01ef907f898f0287766c0dd1be73c55a35516e54adda7b1b3c5b798c9
4
- data.tar.gz: 12e28effd0aaf9a7aaf52dd297a7ef6412f24514ed3940c34649ecf9b968f27b
3
+ metadata.gz: ea0940fbcb4f6adcee05932e3706029dfd00c20aae778925992fd20c4e4122d7
4
+ data.tar.gz: d00eb2084014053724e2c8dfdc6e4b1595bdf3edbfd7d3185d6edcc628bbfdd1
5
5
  SHA512:
6
- metadata.gz: e400a1dd7cc3d3287d478bd4b52ae5964af1dca75138b680368eb5192ae3c57fda3a1924ea168b8c1ec21bd9cc7ca63b23b787d2484361e6e6dfcc9ee8f768ed
7
- data.tar.gz: 517ba241db1d5bee9ae502e1a71847e7196447c53e84721cd2ce92e3b1452c1630d3331aae76cc5ae085f22289a67a4a35ad7f85fe48bccb8ca4368e8ee06b06
6
+ metadata.gz: aee1f47d1bac9b8082fb450f6b14e4225d6af5b30233cc129fa6b1559871d0901b40924ebbbf163ab88ea493a124cddacb3725fb087acdab47b58fd8088906ab
7
+ data.tar.gz: 672f554707e987a99d6dae554b2585a0bf4e7add4f69187e8a06df303e61e94938d6af17e28a54f2c8aa4624b6bafb3f2a9c2b5d56de06a69b14e7ab7bbe4a64
@@ -53,8 +53,9 @@ class Renuo::Cli::Commands::ConfigureSemaphore
53
53
  end
54
54
 
55
55
  def create_semaphore_deployment_targets
56
- system("sem create dt main -p #{project_name}")
57
- system("sem create dt develop -p #{project_name}")
56
+ %w[main develop].each do |branch|
57
+ system("sem create dt #{branch} -p #{project_name} --object-rule branch,exact,#{branch}")
58
+ end
58
59
  end
59
60
 
60
61
  def render(template_file)
@@ -108,7 +108,9 @@ class Renuo::Cli::Commands::Release # rubocop:disable Metrics/ClassLength
108
108
  end
109
109
 
110
110
  def checkout_project
111
- abort(">> Project not found.") unless system("#{MOVE_TO_TMP_FOLDER} && git clone git@#{@host}:#{@repo_path}.git")
111
+ # Blobless clone: file contents are only fetched when needed, so large files in the history are skipped
112
+ clone = "git clone --filter=blob:none git@#{@host}:#{@repo_path}.git"
113
+ abort(">> Project not found.") unless system("#{MOVE_TO_TMP_FOLDER} && #{clone}")
112
114
 
113
115
  system(cmd_in_folder("git checkout #{main_branch} && git pull origin #{main_branch} &&" \
114
116
  "git checkout #{develop_branch} && git pull origin #{develop_branch}"))
@@ -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
+ .pluck("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