diff --git a/docker-compose.yml b/docker-compose.yml index 17799ffd..ebb40c33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: - ~/.ssh:/root/.ssh - ~/.bash_history:/root/.bash_history - ~/.config/uffizzi:/root/.config/uffizzi + - ~/test/uffizzi_app/charts/uffizzi-app:/gem/tmp/charts/uffizzi_app + - ~/test/uffizzi_controller_os/charts/uffizzi-controller:/gem/tmp/charts/uffizzi-controller - bundle_cache:/bundle_cache environment: - BUNDLE_PATH=/bundle_cache diff --git a/lib/uffizzi/cli.rb b/lib/uffizzi/cli.rb index e6a99137..146e7ade 100644 --- a/lib/uffizzi/cli.rb +++ b/lib/uffizzi/cli.rb @@ -75,6 +75,10 @@ def disconnect(credential_type) require_relative 'cli/dev' subcommand 'dev', Cli::Dev + 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..4a4c464f --- /dev/null +++ b/lib/uffizzi/cli/install.rb @@ -0,0 +1,317 @@ +# 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' + DEFAULT_NAMESPACE = 'uffizzi' + DEFAULT_APP_PREFIX = 'uffizzi' + DEFAULT_CLUSTER_ISSUER = 'letsencrypt' + + desc 'application', 'Install uffizzi to cluster' + method_option :namespace, type: :string + method_option :domain, type: :string + method_option :'user-email', type: :string + method_option :'user-password', type: :string + method_option :issuer, type: :string, enum: ['letsencrypt', 'zerossl'] + method_option :repo, type: :string + method_option :'print-values', type: :boolean + def application + run_installation do + if options.except(:repo, :'print-values').present? + build_installation_options + else + ask_installation_params + end + end + end + + desc 'wildcard-tls', 'Add wildcard tls from files' + method_option :domain, type: :string + method_option :cert, type: :string + method_option :key, type: :string + method_option :namespace, type: :string + method_option :repo, type: :string + def wildcard_tls + kubectl_exists? + + params = if options.except(:repo).present? && wildcard_tls_options_valid? + { + namespace: options[:namespace] || DEFAULT_NAMESPACE, + domain: options[:domain], + wildcard_cert_path: options[:cert], + wildcard_key_path: options[:key], + } + else + namespace = Uffizzi.prompt.ask('Namespace: ', required: true, default: DEFAULT_NAMESPACE) + domain = Uffizzi.prompt.ask('Root Domain: ', required: true, default: 'example.com') + wildcard_cert_paths = ask_wildcard_cert(has_user_wildcard_cert: true, domain: domain) + + { namespace: namespace, domain: domain }.merge(wildcard_cert_paths) + end + + kubectl_add_wildcard_tls(params) + helm_values = helm_get_values(namespace, namespace) + helm_values['uffizzi-controller']['tlsPerDeploymentEnabled'] = false.to_s + create_helm_values_file(helm_values) + helm_set_repo unless options[:repo] + helm_install(release_name: namespace, namespace: namespace, repo: options[:repo]) + end + + default_task :application + + private + + def wildcard_tls_options_valid? + required_options = [:namespace, :domain, :cert, :key] + missing_options = required_options - options.symbolize_keys.keys + + return true if missing_options.empty? + + rendered_missing_options = missing_options.map { |o| "'--#{o}'" }.join(', ') + + Uffizzi.ui.say_error_and_exit("No value provided for required options #{rendered_missing_options}") + end + + def run_installation + kubectl_exists? + helm_exists? + params = yield + helm_values = build_helm_values(params) + return Uffizzi.ui.say(helm_values.to_yaml) if options[:'print-values'] + + namespace = params[:namespace] + release_name = params[:namespace] + + create_helm_values_file(helm_values) + helm_set_repo unless options[:repo] + helm_install(release_name: release_name, namespace: namespace, repo: options[:repo]) + delete_helm_values_file + + ingress_ip = get_web_ingress_ip_address(release_name, namespace) + + Uffizzi.ui.say('Helm release is deployed') + Uffizzi.ui.say("The uffizzi application url is 'https://#{DEFAULT_APP_PREFIX}.#{params[:domain]}'") + Uffizzi.ui.say("Create a DNS A record for domain '*.#{params[:domain]}' with value '#{ingress_ip}'") + end + + def get_web_ingress_ip_address(release_name, namespace) + Uffizzi.ui.say('Getting an ingress ip address...') + + 10.times do + web_ingress = kubectl_get_web_ingress(release_name, namespace) + ingresses = web_ingress.dig('status', 'loadBalancer', 'ingress') || [] + ip_address = ingresses.first&.fetch('ip', nil) + + return ip_address if ip_address.present? + + sleep(1) + end + + Uffizzi.ui.say_error_and_exit('We can`t get the uffizzi ingress ip address') + 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_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_install(release_name:, namespace:, repo:) + Uffizzi.ui.say('Start helm release installation') + + repo = repo || "#{HELM_REPO_NAME}/#{CHART_NAME}" + cmd = "helm upgrade #{release_name} #{repo}" \ + " --values #{helm_values_file_path}" \ + " --namespace #{namespace}" \ + ' --create-namespace' \ + ' --install' \ + ' --output json' + + res = execute_command(cmd, say: false) + info = JSON.parse(res)['info'] + + return if info['status'] == HELM_DEPLOYED_STATUS + + Uffizzi.ui.say_error_and_exit(info) + end + + def helm_get_values(release_name, namespace) + cmd = "helm get values #{release_name} -n #{namespace} -o json" + res = execute_command(cmd, say: false) + JSON.parse(res) + 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 kubectl_get_web_ingress(release_name, namespace) + cmd = "kubectl get ingress/#{release_name}-web-ingress -n #{namespace} -o json" + + res = execute_command(cmd, say: false) + JSON.parse(res) + end + + def ask_wildcard_cert(has_user_wildcard_cert: nil, domain: nil) + has_user_wildcard_cert ||= Uffizzi.prompt.yes?('Uffizzi use a wildcard tls certificate. Do you have it?') + + if !has_user_wildcard_cert + Uffizzi.ui.say('Uffizzi does not work properly without a wildcard certificate.') + Uffizzi.ui.say('You can add wildcard cert later with command:') + Uffizzi.ui.say("uffizzi install wildcard-tls --domain #{domain} --cert /path/to/cert --key /path/to/key") + + return {} + end + + cert_path = Uffizzi.prompt.ask('Path to cert: ', required: true) + Uffizzi.ui.say_error_and_exit("File '#{cert_path}' does not exists") unless File.exist?(cert_path) + + key_path = Uffizzi.prompt.ask('Path to key: ', required: true) + Uffizzi.ui.say_error_and_exit("File '#{key_path}' does not exists") unless File.exist?(key_path) + + { wildcard_cert_path: cert_path, wildcard_key_path: key_path } + end + + def ask_installation_params + namespace = Uffizzi.prompt.ask('Namespace: ', required: true, default: DEFAULT_NAMESPACE) + domain = Uffizzi.prompt.ask('Root domain: ', required: true, default: 'example.com') + user_email = Uffizzi.prompt.ask('First user email: ', required: true, default: "admin@#{domain}") + user_password = Uffizzi.prompt.ask('First user password: ', required: true, default: generate_password) + wildcard_cert_paths = ask_wildcard_cert(domain: domain) + + { + namespace: namespace, + domain: domain, + user_email: user_email, + user_password: user_password, + controller_password: generate_password, + cert_email: user_email, + cluster_issuer: DEFAULT_CLUSTER_ISSUER, + }.merge(wildcard_cert_paths) + end + + def build_installation_options + { + namespace: options[:namespace] || DEFAULT_NAMESPACE, + domain: options[:domain], + user_email: options[:'user-email'] || "admin@#{options[:domain]}", + user_password: options[:'user-password'] || generate_password, + controller_password: generate_password, + cert_email: options[:'user-email'], + cluster_issuer: options[:issuer] || DEFAULT_CLUSTER_ISSUER, + } + end + + def build_helm_values(params) + domain = params.fetch(:domain) + namespace = params.fetch(:namespace) + app_host = [DEFAULT_APP_PREFIX, 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: { + disabled: true, + }, + clusterIssuer: params.fetch(:cluster_issuer), + tlsPerDeploymentEnabled: true.to_s, + certEmail: params.fetch(:cert_email), + 'ingress-nginx' => { + controller: { + ingressClassResource: { + default: true, + }, + extraArgs: { + 'default-ssl-certificate' => "#{namespace}/wildcard.#{domain}", + }, + }, + }, + }, + }.deep_stringify_keys + end + + def execute_command(command, say: true) + stdout_str, stderr_str, status = Uffizzi.ui.capture3(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 delete_helm_values_file + File.delete(helm_values_file_path) if File.exist?(helm_values_file_path) + 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/man/uffizzi-install b/man/uffizzi-install new file mode 100644 index 00000000..a13338b5 --- /dev/null +++ b/man/uffizzi-install @@ -0,0 +1,76 @@ +.\" generated with Ronn-NG/v0.9.1 +.\" http://github.com/apjanke/ronn-ng/tree/0.9.1 +.TH "INSTALL" "" "September 2023" "" +.SH "NAME" +\fBinstall\fR \- install the Uffizzi application to cluster +.SH "SYNOPSIS" +.nf +uffizzi install COMMAND +.fi +.SH "DESCRIPTION" +.nf +The uffizzi install command lets you deploy uffizzi application to your kubecrnetes cluster\. +If COMMAND is not specified, uffizzi install start installation\. +if OPTIONS not specified, uffizzi show installation wizard\. + +For more information on configuration options, see: +https://docs\.uffizzi\.com/references/cli/ +.fi +.SH "COMMANDS" +.nf +COMMAND is one of the following: + + wildcard_tls OPTION + Add the wildcard tls certificate to installed uffizzi application\. +.fi +.SH "OPTIONS" +.nf + OPTION is one of the following: + + namespace + The namespace of the kubecrnetes cluster where application will be deployed\. + Default is uffizzi\. + + domain + The domain that will be used for access the web API\. + + issuer + The cluster issuer that will be used for generate tls certificates\. + Default is letsencrypt\. + + user\-email + The login that will be used for access to web API\. + + user\-password + The password that will be used for access to web API\. + + acme\-email + Email address for ACME registration + + wildcard\-cert\-path + Path to wildcard certificate\. + + wildcard\-key\-path + Path to wildcard certificate key\. + + without\-wildcard\-tls + Set this flag and we can install application without wildcard certificate\. + + print\-values + Show builded vales for helm installation\. + The installation will not be executed\. + + repo + The repository that will be used for helm install +.fi +.SH "EXAMPLES" +.nf +To install the uffizzi command, run: + + $ uffizzi install + +To install the wildcard_tls command, run: + + $ uffizzi install wildcard_tls +.fi + diff --git a/man/uffizzi-install.ronn b/man/uffizzi-install.ronn new file mode 100644 index 00000000..c9f6cf62 --- /dev/null +++ b/man/uffizzi-install.ronn @@ -0,0 +1,67 @@ +uffizzi install - install the Uffizzi application to cluster +================================================================ + +## SYNOPSIS + uffizzi install COMMAND + +## DESCRIPTION + The uffizzi install command lets you deploy uffizzi application to your kubecrnetes cluster. + If COMMAND is not specified, uffizzi install start installation. + if OPTIONS not specified, uffizzi show installation wizard. + + For more information on configuration options, see: + https://docs.uffizzi.com/references/cli/ + +## COMMANDS + COMMAND is one of the following: + + wildcard_tls OPTION + Add the wildcard tls certificate to installed uffizzi application. + +## OPTIONS + OPTION is one of the following: + + namespace + The namespace of the kubecrnetes cluster where application will be deployed. + Default is uffizzi. + + domain + The domain that will be used for access the web API. + + issuer + The cluster issuer that will be used for generate tls certificates. + Default is letsencrypt. + + user-email + The login that will be used for access to web API. + + user-password + The password that will be used for access to web API. + + acme-email + Email address for ACME registration + + wildcard-cert-path + Path to wildcard certificate. + + wildcard-key-path + Path to wildcard certificate key. + + without-wildcard-tls + Set this flag and we can install application without wildcard certificate. + + print-values + Show builded vales for helm installation. + The installation will not be executed. + + repo + The repository that will be used for helm install + +## EXAMPLES + To install the uffizzi command, run: + + $ uffizzi install + + To install the wildcard_tls command, run: + + $ uffizzi install wildcard_tls diff --git a/test/support/mocks/mock_shell.rb b/test/support/mocks/mock_shell.rb index ce1cf024..dc03dec7 100644 --- a/test/support/mocks/mock_shell.rb +++ b/test/support/mocks/mock_shell.rb @@ -2,7 +2,6 @@ class MockShell class ExitError < StandardError; end - class MockProcessStatus def initialize(success) @success = success @@ -109,6 +108,23 @@ def promise_execute(command, stdout: nil, stderr: nil, waiter: nil) 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/uffizzi/cli/install_test.rb b/test/uffizzi/cli/install_test.rb new file mode 100644 index 00000000..5971ca6f --- /dev/null +++ b/test/uffizzi/cli/install_test.rb @@ -0,0 +1,51 @@ +# 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('Namespace: ', 'uffizzi') + @mock_prompt.promise_question_answer('Root domain: ', 'my-domain.com') + @mock_prompt.promise_question_answer('First user email: ', 'admin@my-domain.com') + @mock_prompt.promise_question_answer('First user password: ', 'password') + @mock_prompt.promise_question_answer('Uffizzi use a wildcard tls certificate. Do you have it?', 'n') + + @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 upgrade/, stdout: { info: { status: 'deployed' } }.to_json) + @mock_shell.promise_execute(/kubectl get ingress/, stdout: { status: { loadBalancer: { ingress: [{ ip: '34.31.68.232' }] } } }.to_json) + + @install.application + + last_message = Uffizzi.ui.last_message + assert_match('Create a DNS A record for domain', 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 upgrade/, stdout: { info: { status: 'deployed' } }.to_json) + @mock_shell.promise_execute(/kubectl get ingress/, stdout: { status: { loadBalancer: { ingress: [{ ip: '34.31.68.232' }] } } }.to_json) + + @install.options = command_options(domain: 'my-domain.com', 'without-wildcard-tls' => true) + @install.application + + last_message = Uffizzi.ui.last_message + assert_match('Create a DNS A record for domain', last_message) + end +end diff --git a/test/uffizzi/cli/login_test.rb b/test/uffizzi/cli/login_test.rb index a03ba12b..732da2a1 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),