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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b4a37d01ef907f898f0287766c0dd1be73c55a35516e54adda7b1b3c5b798c9
4
- data.tar.gz: 12e28effd0aaf9a7aaf52dd297a7ef6412f24514ed3940c34649ecf9b968f27b
3
+ metadata.gz: 352c9cb95e2c7548026938080ff46625617536757aa4653aa3412ac35dead312
4
+ data.tar.gz: dc0de5b1c6781136fe7b13d441c823b648a5efa36c3d28c1cc74e1f8ed8c2e2b
5
5
  SHA512:
6
- metadata.gz: e400a1dd7cc3d3287d478bd4b52ae5964af1dca75138b680368eb5192ae3c57fda3a1924ea168b8c1ec21bd9cc7ca63b23b787d2484361e6e6dfcc9ee8f768ed
7
- data.tar.gz: 517ba241db1d5bee9ae502e1a71847e7196447c53e84721cd2ce92e3b1452c1630d3331aae76cc5ae085f22289a67a4a35ad7f85fe48bccb8ca4368e8ee06b06
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
- fetch_vms_cmd = %(nctl get cloudvm -A -o yaml)
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 = Open3.capture3(cmd)
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
@@ -3,7 +3,7 @@
3
3
  # simplecov:disable
4
4
  module Renuo
5
5
  class Cli
6
- VERSION = "4.27.0"
6
+ VERSION = "4.28.0"
7
7
  NAME = "renuo-cli"
8
8
  end
9
9
  end
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.27.0
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