diff --git a/lib/uffizzi/cli.rb b/lib/uffizzi/cli.rb index 3662c902..19b82376 100644 --- a/lib/uffizzi/cli.rb +++ b/lib/uffizzi/cli.rb @@ -71,6 +71,10 @@ def disconnect(credential_type) Disconnect.new.run(credential_type) end + desc 'install', 'install' + require_relative 'cli/install' + subcommand 'install', Cli::Install + map preview: :compose class << self diff --git a/lib/uffizzi/cli/install.rb b/lib/uffizzi/cli/install.rb new file mode 100644 index 00000000..76845f87 --- /dev/null +++ b/lib/uffizzi/cli/install.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +require 'uffizzi' +require 'uffizzi/config_file' + +module Uffizzi + class Cli::Install < Thor + HELM_REPO_NAME = 'uffizzi' + HELM_DEPLOYED_STATUS = 'deployed' + CHART_NAME = 'uffizzi-app' + VALUES_FILE_NAME = 'helm_values.yaml' + + desc 'by-wizard [NAMESPACE]', 'Install uffizzi to cluster' + def by_wizard(namespace) + run_installation do + ask_installation_params(namespace) + end + end + + desc 'by-options [NAMESPACE]', 'Install uffizzi to cluster' + method_option :domain, required: true, type: :string, aliases: '-d' + method_option :'user-email', required: false, type: :string, aliases: '-e' + method_option :'acme-email', required: false, type: :string + method_option :'user-password', required: false, type: :string + method_option :'controller-password', required: false, type: :string + method_option :issuer, type: :string, enum: ['letsencrypt', 'zerossl'], default: 'letsencrypt' + method_option :'wildcard-cert-path', required: false, type: :string + method_option :'wildcard-key-path', required: false, type: :string + method_option :'without-wildcard-tls', required: false, type: :boolean + def by_options(namespace) + run_installation do + validate_installation_options(namespace, options) + end + end + + desc 'add-wildcard-tls [NAMESPACE]', 'Add wildcard tls from files' + method_option :cert, required: true, type: :string, aliases: '-c' + method_option :key, required: true, type: :string, aliases: '-k' + method_option :domain, required: true, type: :string, aliases: '-d' + def add_wildcard_tls(namespace) + kubectl_exists? + + params = { + namespace: namespace, + domain: options[:domain], + wildcard_cert_path: options[:cert], + wildcard_key_path: options[:key], + } + + kubectl_add_wildcard_tls(params) + end + + private + + def run_installation + kubectl_exists? + helm_exists? + params = yield + helm_values = build_helm_values(params) + create_helm_values_file(helm_values) + helm_set_repo + helm_set_release(params.fetch(:namespace)) + kubectl_add_wildcard_tls(params) if params[:wildcard_cert_path] && params[:wildcard_key_path] + end + + def kubectl_exists? + cmd = 'kubectl version -o json' + execute_command(cmd, say: false).present? + end + + def helm_exists? + cmd = 'helm version --short' + execute_command(cmd, say: false).present? + end + + def helm_set_repo + repo = helm_repo_search + return if repo.present? + + helm_repo_add + end + + def helm_set_release(namespace) + releases = helm_release_list(namespace) + release = releases.detect { |r| r['name'] == namespace } + if release.present? + Uffizzi.ui.say_error_and_exit("The release #{release['name']} already exists with status #{release['status']}") + end + + helm_install(namespace) + end + + def helm_repo_add + cmd = "helm repo add #{HELM_REPO_NAME} https://uffizzicloud.github.io/uffizzi" + execute_command(cmd) + end + + def helm_repo_search + cmd = "helm search repo #{HELM_REPO_NAME}/#{CHART_NAME} -o json" + + execute_command(cmd) do |result, err| + err.present? ? nil : JSON.parse(result) + end + end + + def helm_release_list(namespace) + cmd = "helm list -n #{namespace} -o json" + result = execute_command(cmd, say: false) + + JSON.parse(result) + end + + def helm_install(namespace) + release_name = namespace + cmd = "helm install #{release_name} #{HELM_REPO_NAME}/#{CHART_NAME}" \ + " --values #{helm_values_file_path}" \ + " --namespace #{namespace}" \ + ' --create-namespace' \ + ' --output json' + + res = execute_command(cmd, say: false) + info = JSON.parse(res)['info'] + + return Uffizzi.ui.say('Helm release is deployed') if info['status'] == HELM_DEPLOYED_STATUS + + Uffizzi.ui.say_error_and_exit(info) + end + + def kubectl_add_wildcard_tls(params) + cmd = "kubectl create secret tls wildcard.#{params.fetch(:domain)}" \ + " --cert=#{params.fetch(:wildcard_cert_path)}" \ + " --key=#{params.fetch(:wildcard_key_path)}" \ + " --namespace #{params.fetch(:namespace)}" + + execute_command(cmd) + end + + def ask_wildcard_cert + has_user_wildcard_cert = Uffizzi.prompt.yes?('Uffizzi use a wildcard tls certificate. Do you have it?') + + if has_user_wildcard_cert + cert_path = Uffizzi.prompt.ask('Path to cert: ', required: true) + key_path = Uffizzi.prompt.ask('Path to key: ', required: true) + + return { wildcard_cert_path: cert_path, wildcard_key_path: key_path } + end + + add_later = Uffizzi.prompt.yes?('Do you want to add wildcard certificate later?') + + if add_later + Uffizzi.ui.say('You can set command "uffizzi install add-wildcard-cert [NAMESPACE]'\ + ' -d your.domain.com -c /path/to/cert -k /path/to/key"') + + { wildcard_cert_path: nil, wildcard_key_path: nil } + else + Uffizzi.ui.say('Sorry, but uffizzi can not work correctly without wildcard certificate') + exit(0) + end + end + + def ask_installation_params(namespace) + wildcard_cert_paths = ask_wildcard_cert + domain = Uffizzi.prompt.ask('Domain: ', required: true, default: 'example.com') + user_email = Uffizzi.prompt.ask('User email: ', required: true, default: "admin@#{domain}") + user_password = Uffizzi.prompt.ask('User password: ', required: true, default: generate_password) + controller_password = Uffizzi.prompt.ask('Controller password: ', required: true, default: generate_password) + cert_email = Uffizzi.prompt.ask('Email address for ACME registration: ', required: true, default: user_email) + cluster_issuers = [ + { name: 'Letsencrypt', value: 'letsencrypt' }, + { name: 'ZeroSSL', value: 'zerossl' }, + ] + cluster_issuer = Uffizzi.prompt.select('Cluster issuer', cluster_issuers) + + { + namespace: namespace, + domain: domain, + user_email: user_email, + user_password: user_password, + controller_password: controller_password, + cert_email: cert_email, + cluster_issuer: cluster_issuer, + }.merge(wildcard_cert_paths) + end + + def validate_installation_options(namespace, options) + base_params = { + namespace: namespace, + domain: options[:domain], + user_email: options[:'user-email'] || "admin@#{options[:domain]}", + user_password: options[:'user-password'] || generate_password, + controller_password: options[:'controller-password'] || generate_password, + cert_email: options[:'acme-email'] || options[:'user-email'], + cluster_issuer: options[:issuer], + wildcard_cert_path: nil, + wildcard_key_path: nil, + } + + return base_params if options[:'without-wildcard-tls'] + + empty_key = [:'wildcard-cert-path', :'wildcard-key-path'].detect { |k| options[k].nil? } + + if empty_key.present? + return Uffizzi.ui.say_error_and_exit("#{empty_key} is required or use the flag without-wildcard-tls") + end + + wildcard_params = { + wildcard_cert_path: options[:'wildcard-cert-path'], + wildcard_key_path: options[:'wildcard-key-path'], + } + + base_params.merge(wildcard_params) + end + + def build_helm_values(params) + domain = params.fetch(:domain) + namespace = params.fetch(:namespace) + app_host = ['app', domain].join('.') + + { + app_url: "https://#{app_host}", + webHostname: app_host, + allowed_hosts: app_host, + managed_dns_zone_dns_name: domain, + global: { + uffizzi: { + firstUser: { + email: params.fetch(:user_email), + password: params.fetch(:user_password), + }, + controller: { + password: params.fetch(:controller_password), + }, + }, + }, + 'uffizzi-controller' => { + ingress: { + hostname: "controller.#{domain}", + }, + clusterIssuer: params.fetch(:cluster_issuer), + certEmail: params.fetch(:cert_email), + 'ingress-nginx' => { + controller: { + extraArgs: { + 'default-ssl-certificate' => "#{namespace}/wildcard.#{domain}", + }, + }, + }, + }, + }.deep_stringify_keys + end + + def execute_command(command, say: true) + stdout_str, stderr_str, status = Uffizzi.ui.execute(command) + + return yield(stdout_str, stderr_str) if block_given? + + Uffizzi.ui.say_error_and_exit(stderr_str) unless status.success? + + say ? Uffizzi.ui.say(stdout_str) : stdout_str + rescue Errno::ENOENT => e + Uffizzi.ui.say_error_and_exit(e.message) + end + + def create_helm_values_file(values) + FileUtils.mkdir_p(helm_values_dir_path) unless File.directory?(helm_values_dir_path) + File.write(helm_values_file_path, values.to_yaml) + end + + def helm_values_file_path + File.join(helm_values_dir_path, VALUES_FILE_NAME) + end + + def helm_values_dir_path + File.dirname(Uffizzi::ConfigFile.config_path) + end + + def generate_password + hexatridecimal_base = 36 + length = 8 + rand(hexatridecimal_base**length).to_s(hexatridecimal_base) + end + end +end diff --git a/lib/uffizzi/services/preview_service.rb b/lib/uffizzi/services/preview_service.rb index 0f7c4e13..98b58db4 100644 --- a/lib/uffizzi/services/preview_service.rb +++ b/lib/uffizzi/services/preview_service.rb @@ -56,7 +56,9 @@ def wait_containers_creation(deployment, project_slug) Uffizzi.ui.say('Deployed') Uffizzi.ui.say("Deployment url: https://#{deployment[:preview_url]}") - Uffizzi.ui.say("Deployment proxy url: https://#{deployment[:proxy_preview_url]}") + if deployment[:proxy_preview_url].present? + Uffizzi.ui.say("Deployment proxy url: https://#{deployment[:proxy_preview_url]}") + end activity_items rescue ApiClient::ResponseError => e diff --git a/lib/uffizzi/shell.rb b/lib/uffizzi/shell.rb index 30a4bf42..7601c47f 100644 --- a/lib/uffizzi/shell.rb +++ b/lib/uffizzi/shell.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'awesome_print' +require 'open3' module Uffizzi module UI @@ -54,6 +55,10 @@ def stdout_pipe? $stdout.stat.pipe? end + def execute(command) + Open3.capture3(command) + end + private def format_to_json(data) diff --git a/test/support/mocks/mock_shell.rb b/test/support/mocks/mock_shell.rb index d249e194..f9ce7448 100644 --- a/test/support/mocks/mock_shell.rb +++ b/test/support/mocks/mock_shell.rb @@ -2,6 +2,17 @@ class MockShell class ExitError < StandardError; end + + class MockProcessStatus + def initialize(success) + @success = success + end + + def success? + @success + end + end + attr_accessor :messages, :output_format, :stdout_pipe PRETTY_JSON = 'pretty-json' @@ -10,6 +21,7 @@ class ExitError < StandardError; end def initialize @messages = [] + @command_responses = [] @output_enabled = true @stdout_pipe = false end @@ -56,8 +68,36 @@ def enable_stdout @output_enabled = true end + def execute(command, *_params) + stdout, stderr = get_command_response(command) + status = MockProcessStatus.new(stderr.nil?) + + [stdout, stderr, status] + end + + def promise_execute(command, stdout: nil, stderr: nil) + @command_responses << { command: command, stdout: stdout, stderr: stderr } + end + private + def get_command_response(command) + response_index = @command_responses.index do |command_response| + case command_response[:command] + when Regexp + command_response[:command].match?(command) + else + command_response[:command] == command + end + end + + stdout = @command_responses[response_index].fetch(:stdout) + stderr = @command_responses[response_index].fetch(:stderr) + @command_responses.delete_at(response_index) + + [stdout, stderr] + end + def format_to_json(data) data.to_json end diff --git a/test/test_helper.rb b/test/test_helper.rb index 3edbcd34..53aad191 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -38,8 +38,10 @@ class Minitest::Test def before_setup super + @mock_prompt = MockPrompt.new @mock_shell = MockShell.new - Uffizzi::UI::Shell.stubs(:new).returns(@mock_shell) + Uffizzi.stubs(:ui).returns(@mock_shell) + Uffizzi.stubs(:prompt).returns(@mock_prompt) Uffizzi::ConfigFile.stubs(:config_path).returns(TEST_CONFIG_PATH) Uffizzi::Token.stubs(:token_path).returns(TEST_TOKEN_PATH) end diff --git a/test/uffizzi/cli/install_test.rb b/test/uffizzi/cli/install_test.rb new file mode 100644 index 00000000..46170f9c --- /dev/null +++ b/test/uffizzi/cli/install_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'psych' +require 'base64' +require 'test_helper' + +class InstallTest < Minitest::Test + def setup + @install = Uffizzi::Cli::Install.new + + tmp_dir_name = (Time.now.utc.to_f * 100_000).to_i + helm_values_path = "/tmp/test/#{tmp_dir_name}/helm_values.yaml" + Uffizzi::ConfigFile.stubs(:config_path).returns(helm_values_path) + end + + def test_install_by_wizard + @mock_prompt.promise_question_answer('Uffizzi use a wildcard tls certificate. Do you have it?', 'n') + @mock_prompt.promise_question_answer('Do you want to add wildcard certificate later?', 'y') + @mock_prompt.promise_question_answer('Domain: ', 'my-domain.com') + @mock_prompt.promise_question_answer('User email: ', 'admin@my-domain.com') + @mock_prompt.promise_question_answer('User password: ', 'password') + @mock_prompt.promise_question_answer('Controller password: ', 'password') + @mock_prompt.promise_question_answer('Email address for ACME registration: ', 'admin@my-domain.com') + @mock_prompt.promise_question_answer('Cluster issuer', :first) + + @mock_shell.promise_execute(/kubectl version/, stdout: '1.23.00') + @mock_shell.promise_execute(/helm version/, stdout: '3.00') + @mock_shell.promise_execute(/helm search repo/, stdout: [].to_json) + @mock_shell.promise_execute(/helm repo add/, stdout: 'ok') + @mock_shell.promise_execute(/helm list/, stdout: [].to_json) + @mock_shell.promise_execute(/helm install/, stdout: { info: { status: 'deployed' } }.to_json) + + @install.by_wizard('uffizzi') + + last_message = Uffizzi.ui.last_message + assert_match('deployed', last_message) + end + + def test_install_by_options + @mock_shell.promise_execute(/kubectl version/, stdout: '1.23.00') + @mock_shell.promise_execute(/helm version/, stdout: '3.00') + @mock_shell.promise_execute(/helm search repo/, stdout: [].to_json) + @mock_shell.promise_execute(/helm repo add/, stdout: 'ok') + @mock_shell.promise_execute(/helm list/, stdout: [].to_json) + @mock_shell.promise_execute(/helm install/, stdout: { info: { status: 'deployed' } }.to_json) + + @install.options = command_options(domain: 'my-domain.com', 'without-wildcard-tls' => true) + @install.by_options('uffizzi') + + last_message = Uffizzi.ui.last_message + assert_match('deployed', last_message) + end +end diff --git a/test/uffizzi/cli/login_test.rb b/test/uffizzi/cli/login_test.rb index 20b0c63f..ba10267a 100644 --- a/test/uffizzi/cli/login_test.rb +++ b/test/uffizzi/cli/login_test.rb @@ -5,8 +5,6 @@ class LoginTest < Minitest::Test def setup @cli = Uffizzi::Cli.new - @mock_prompt = MockPrompt.new - Uffizzi.stubs(:prompt).returns(@mock_prompt) @command_params = { username: generate(:email),