diff --git a/Gemfile.lock b/Gemfile.lock index 489774d9..250a1e57 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - uffizzi-cli (2.2.0) + uffizzi-cli (2.2.1) activesupport awesome_print faker @@ -100,9 +100,9 @@ GEM rubocop (~> 1.0) ruby-progressbar (1.11.0) securerandom (0.2.2) - sentry-ruby (5.11.0) + sentry-ruby (5.12.0) concurrent-ruby (~> 1.0, >= 1.0.2) - thor (1.2.2) + thor (1.3.0) tty-color (0.6.0) tty-cursor (0.7.1) tty-prompt (0.23.1) diff --git a/Makefile b/Makefile index 68583f56..2826148b 100644 --- a/Makefile +++ b/Makefile @@ -67,4 +67,10 @@ gem_build_install: gem_uninstall: gem uninstall uffizzi-cli +brew_add_tap: + brew tap UffizziCloud/tap + +brew_tap_install: + brew install uffizzicloud/tap/uffizzi + .PHONY: test diff --git a/lib/uffizzi/auth_helper.rb b/lib/uffizzi/auth_helper.rb index b35e1fe9..37204b55 100644 --- a/lib/uffizzi/auth_helper.rb +++ b/lib/uffizzi/auth_helper.rb @@ -16,7 +16,7 @@ def sign_out Uffizzi::Token.delete if Uffizzi::Token.exists? end - def check_login(project_option) + def check_login(project_option = nil) raise Uffizzi::Error.new('You are not logged in. Run `uffizzi login`.') unless signed_in? raise Uffizzi::Error.new('This command needs project to be set in config file') unless project_set?(project_option) end diff --git a/lib/uffizzi/cli/cluster.rb b/lib/uffizzi/cli/cluster.rb index e50715e2..07d78ba9 100644 --- a/lib/uffizzi/cli/cluster.rb +++ b/lib/uffizzi/cli/cluster.rb @@ -76,19 +76,18 @@ def wake(name = nil) def run(command, command_args = {}) Uffizzi.ui.output_format = options[:output] Uffizzi::AuthHelper.check_login(options[:project]) - project_slug = options[:project].nil? ? ConfigFile.read_option(:project) : options[:project] case command when 'list' - handle_list_command(project_slug) + handle_list_command when 'create' - handle_create_command(project_slug, command_args) + handle_create_command(command_args) when 'describe' - handle_describe_command(project_slug, command_args) + handle_describe_command(command_args) when 'delete' - handle_delete_command(project_slug, command_args) + handle_delete_command(command_args) when 'update-kubeconfig' - handle_update_kubeconfig_command(project_slug, command_args) + handle_update_kubeconfig_command(command_args) when 'disconnect' ClusterDisconnectService.handle(options) when 'sleep' @@ -98,13 +97,12 @@ def run(command, command_args = {}) end end - def handle_list_command(project_slug) + def handle_list_command is_all = options[:all] response = if is_all - get_account_clusters(ConfigFile.read_option(:server), ConfigFile.read_option(:account, :id)) + get_account_clusters(server, ConfigFile.read_option(:account, :id)) else - oidc_token = ConfigFile.read_option(:oidc_token) - get_project_clusters(ConfigFile.read_option(:server), project_slug, oidc_token: oidc_token) + get_project_clusters(server, project_slug, oidc_token: oidc_token) end if ResponseHelper.ok?(response) @@ -115,7 +113,7 @@ def handle_list_command(project_slug) end # rubocop:disable Metrics/PerceivedComplexity - def handle_create_command(project_slug, command_args) + def handle_create_command(command_args) Uffizzi.ui.disable_stdout if Uffizzi.ui.output_format if options[:name] @@ -125,23 +123,20 @@ def handle_create_command(project_slug, command_args) end cluster_name = command_args[:name] || options[:name] || ClusterService.generate_name - creation_source = options[:"creation-source"] || ClusterService::MANUAL_CREATION_SOURCE - k8s_version = options[:"k8s-version"] Uffizzi.ui.say_error_and_exit("Cluster name: #{cluster_name} is not valid.") unless ClusterService.valid_name?(cluster_name) - params = cluster_creation_params( - name: cluster_name, - creation_source: creation_source, - manifest_file_path: options[:manifest], - k8s_version: k8s_version, - ) - response = create_cluster(ConfigFile.read_option(:server), project_slug, params) + unless ClusterService.valid_name?(cluster_name) + Uffizzi.ui.say_error_and_exit("Cluster name: #{cluster_name} is not valid.") + end + + params = cluster_creation_params(cluster_name) + response = create_cluster(server, project_slug, params) return ResponseHelper.handle_failed_response(response) unless ResponseHelper.created?(response) spinner = TTY::Spinner.new("[:spinner] Creating cluster #{cluster_name}...", format: :dots) spinner.auto_spin - cluster_data = ClusterService.wait_cluster_deploy(project_slug, cluster_name, ConfigFile.read_option(:oidc_token)) + cluster_data = ClusterService.wait_cluster_deploy(project_slug, cluster_name, oidc_token) if ClusterService.failed?(cluster_data[:state]) spinner.error @@ -151,26 +146,28 @@ def handle_create_command(project_slug, command_args) spinner.success handle_succeed_create_response(cluster_data) rescue SystemExit, Interrupt, SocketError - handle_interrupt_creation(cluster_name, ConfigFile.read_option(:server), project_slug) + handle_interrupt_creation(cluster_name) end # rubocop:enable Metrics/PerceivedComplexity - def handle_describe_command(project_slug, command_args) - cluster_data = fetch_cluster_data(project_slug, command_args[:cluster_name]) + def handle_describe_command(command_args) + cluster_data = ClusterService.fetch_cluster_data(command_args[:cluster_name], **cluster_api_connection_params) + render_data = ClusterService.build_render_data(cluster_data) - handle_succeed_describe(cluster_data) + Uffizzi.ui.output_format = Uffizzi::UI::Shell::PRETTY_LIST + Uffizzi.ui.say(render_data) end - def handle_delete_command(project_slug, command_args) + def handle_delete_command(command_args) cluster_name = command_args[:cluster_name] is_delete_kubeconfig = options[:'delete-config'] - return handle_delete_cluster(project_slug, cluster_name) unless is_delete_kubeconfig + return handle_delete_cluster(cluster_name) unless is_delete_kubeconfig - cluster_data = fetch_cluster_data(project_slug, cluster_name) + cluster_data = ClusterService.fetch_cluster_data(cluster_name, **cluster_api_connection_params) kubeconfig = parse_kubeconfig(cluster_data[:kubeconfig]) - handle_delete_cluster(project_slug, cluster_name) + handle_delete_cluster(cluster_name) exclude_kubeconfig(cluster_data[:id], kubeconfig) if kubeconfig.present? end @@ -194,12 +191,12 @@ def exclude_kubeconfig(cluster_id, kubeconfig) end end - def handle_delete_cluster(project_slug, cluster_name) + def handle_delete_cluster(cluster_name) params = { cluster_name: cluster_name, - oidc_token: ConfigFile.read_option(:oidc_token), + oidc_token: oidc_token, } - response = delete_cluster(ConfigFile.read_option(:server), project_slug, params) + response = delete_cluster(server, project_slug, params) if ResponseHelper.no_content?(response) Uffizzi.ui.say("Cluster #{cluster_name} deleted") @@ -208,10 +205,10 @@ def handle_delete_cluster(project_slug, cluster_name) end end - def handle_update_kubeconfig_command(project_slug, command_args) + def handle_update_kubeconfig_command(command_args) kubeconfig_path = options[:kubeconfig] || KubeconfigService.default_path cluster_name = command_args[:cluster_name] - cluster_data = fetch_cluster_data(project_slug, cluster_name) + cluster_data = ClusterService.fetch_cluster_data(cluster_name, **cluster_api_connection_params) unless cluster_data[:kubeconfig].present? say_error_update_kubeconfig(cluster_data) @@ -289,13 +286,15 @@ def say_error_update_kubeconfig(cluster_data) end end - def cluster_creation_params(name:, creation_source:, manifest_file_path:, k8s_version:) + def cluster_creation_params(cluster_name) + creation_source = options[:"creation-source"] || ClusterService::MANUAL_CREATION_SOURCE + manifest_file_path = options[:manifest] + k8s_version = options[:"k8s-version"] manifest_content = load_manifest_file(manifest_file_path) - oidc_token = Uffizzi::ConfigFile.read_option(:oidc_token) { cluster: { - name: name, + name: cluster_name, manifest: manifest_content, creation_source: creation_source, k8s_version: k8s_version, @@ -312,7 +311,7 @@ def load_manifest_file(file_path) raise Uffizzi::Error.new(e.message) end - def handle_interrupt_creation(cluster_name, server, project_slug) + def handle_interrupt_creation(cluster_name) deletion_response = delete_cluster(server, project_slug, cluster_name: cluster_name) deletion_message = if ResponseHelper.no_content?(deletion_response) "The cluster #{cluster_name} has been disabled." @@ -348,24 +347,6 @@ def render_plain_cluster_list(clusters) end.join("\n") end - def handle_succeed_describe(cluster_data) - prepared_cluster_data = { - name: cluster_data[:name], - status: cluster_data[:state], - created: Time.strptime(cluster_data[:created_at], '%Y-%m-%dT%H:%M:%S.%N').strftime('%a %b %d %H:%M:%S %Y'), - url: cluster_data[:host], - k8s_version: cluster_data[:k8s_version], - } - - rendered_cluster_data = if Uffizzi.ui.output_format.nil? - prepared_cluster_data.map { |k, v| "- #{k.to_s.upcase}: #{v}" }.join("\n").strip - else - prepared_cluster_data - end - - Uffizzi.ui.say(rendered_cluster_data) - end - def handle_succeed_create_response(cluster_data) kubeconfig_path = options[:kubeconfig] || KubeconfigService.default_path is_update_current_context = options[:'update-current-context'] @@ -432,20 +413,6 @@ def parse_kubeconfig(kubeconfig) Psych.safe_load(Base64.decode64(kubeconfig)) end - def fetch_cluster_data(project_slug, cluster_name) - params = { - cluster_name: cluster_name, - oidc_token: ConfigFile.read_option(:oidc_token), - } - response = get_cluster(ConfigFile.read_option(:server), project_slug, params) - - if ResponseHelper.ok?(response) - response.dig(:body, :cluster) - else - ResponseHelper.handle_failed_response(response) - end - end - def save_previous_current_context(kubeconfig_path, current_context) return if kubeconfig_path.nil? || ConfigHelper.previous_current_context_by_path(kubeconfig_path).present? @@ -458,5 +425,25 @@ def handle_missing_cluster_name_error Uffizzi.ui.say('Please update the current context or provide a cluster name.') Uffizzi.ui.say('$uffizzi cluster sleep my-cluster') end + + def cluster_api_connection_params + { + server: server, + project_slug: project_slug, + oidc_token: oidc_token, + } + end + + def oidc_token + @oidc_token ||= ConfigFile.read_option(:oidc_token) + end + + def project_slug + @project_slug ||= options[:project].nil? ? ConfigFile.read_option(:project) : options[:project] + end + + def server + @server ||= ConfigFile.read_option(:server) + end end end diff --git a/lib/uffizzi/cli/dev.rb b/lib/uffizzi/cli/dev.rb index 5b8a5111..b96f48d0 100644 --- a/lib/uffizzi/cli/dev.rb +++ b/lib/uffizzi/cli/dev.rb @@ -15,67 +15,110 @@ class Cli::Dev < Thor method_option :kubeconfig, type: :string method_option :'k8s-version', required: false, type: :string def start(config_path = 'skaffold.yaml') - Uffizzi::AuthHelper.check_login(options[:project]) + run('start', config_path: config_path) + end + + desc 'stop', 'Stop dev environment' + def stop + run('stop') + end + + desc 'describe', 'Describe dev environment' + def describe + run('describe') + end + + desc 'delete', 'Delete dev environment' + def delete + run('delete') + end + + private + + def run(command, command_args = {}) + Uffizzi::AuthHelper.check_login + + case command + when 'start' + handle_start_command(command_args) + when 'stop' + handle_stop_command + when 'describe' + handle_describe_command + when 'delete' + handle_delete_command + end + end + + def handle_start_command(command_args) + config_path = command_args[:config_path] DevService.check_skaffold_existence - DevService.check_running_daemon if options[:quiet] + DevService.check_no_running_process! DevService.check_skaffold_config_existence(config_path) - cluster_id, cluster_name = start_create_cluster - kubeconfig = wait_cluster_creation(cluster_name) + + if dev_environment.empty? + DevService.set_startup_state + cluster_name = start_create_cluster + wait_cluster_creation(cluster_name) + DevService.set_dev_environment_config(cluster_name, config_path, options) + DevService.set_cluster_deployed_state + end if options[:quiet] launch_demonise_skaffold(config_path) else - DevService.start_basic_skaffold(config_path, options) - end - ensure - if defined?(cluster_name).present? && defined?(cluster_id).present? - kubeconfig = defined?(kubeconfig).present? ? kubeconfig : nil - handle_delete_cluster(cluster_id, cluster_name, kubeconfig) + launch_basic_skaffold(config_path) end end - desc 'stop', 'Stop dev environment' - def stop - return Uffizzi.ui.say('Uffizzi dev is not running') unless File.exist?(DevService.pid_path) + def handle_stop_command + DevService.check_running_process! + DevService.stop_process + Uffizzi.ui.say('Uffizzi dev was stopped') + end - pid = File.read(DevService.pid_path).to_i - File.delete(DevService.pid_path) + def handle_describe_command + DevService.check_environment_exist! - Uffizzi.process.kill('QUIT', pid) - Uffizzi.ui.say('Uffizzi dev was stopped') - rescue Errno::ESRCH - Uffizzi.ui.say('Uffizzi dev is not running') - File.delete(DevService.pid_path) + cluster_data = fetch_dev_env_cluster! + cluster_render_data = ClusterService.build_render_data(cluster_data) + dev_environment_render_data = cluster_render_data.merge(config_path: dev_environment[:config_path]) + + Uffizzi.ui.output_format = Uffizzi::UI::Shell::PRETTY_LIST + Uffizzi.ui.say(dev_environment_render_data) end - private + def handle_delete_command + DevService.check_environment_exist! + + if DevService.process_running? + DevService.stop_process + Uffizzi.ui.say('Uffizzi dev was stopped') + end + + cluster_data = fetch_dev_env_cluster! + handle_delete_cluster(cluster_data) + DevService.clear_dev_environment_config + end def start_create_cluster - params = cluster_creation_params( - name: ClusterService.generate_name, - creation_source: ClusterService::MANUAL_CREATION_SOURCE, - k8s_version: options[:"k8s-version"], - ) + params = cluster_creation_params Uffizzi.ui.say('Start creating a cluster') - response = create_cluster(ConfigFile.read_option(:server), project_slug, params) + response = create_cluster(server, project_slug, params) return ResponseHelper.handle_failed_response(response) unless ResponseHelper.created?(response) - cluster_id = response.dig(:body, :cluster, :id) - cluster_name = response.dig(:body, :cluster, :name) - - [cluster_id, cluster_name] + response.dig(:body, :cluster, :name) end def wait_cluster_creation(cluster_name) Uffizzi.ui.say('Checking the cluster status...') - cluster_data = ClusterService.wait_cluster_deploy(project_slug, cluster_name, ConfigFile.read_option(:oidc_token)) + cluster_data = ClusterService.wait_cluster_deploy(project_slug, cluster_name, oidc_token) if ClusterService.failed?(cluster_data[:state]) Uffizzi.ui.say_error_and_exit("Cluster with name: #{cluster_name} failed to be created.") end handle_succeed_cluster_creation(cluster_data) - parse_kubeconfig(cluster_data[:kubeconfig]) end def handle_succeed_cluster_creation(cluster_data) @@ -109,30 +152,30 @@ def update_clusters_config(id, params) ConfigFile.write_option(:clusters, clusters_config) end - def cluster_creation_params(name:, creation_source:, k8s_version:) - oidc_token = Uffizzi::ConfigFile.read_option(:oidc_token) - + def cluster_creation_params { cluster: { - name: name, + name: ClusterService.generate_name, manifest: nil, - creation_source: creation_source, - k8s_version: k8s_version, + creation_source: ClusterService::MANUAL_CREATION_SOURCE, + k8s_version: options[:"k8s-version"], }, token: oidc_token, } end - def handle_delete_cluster(cluster_id, cluster_name, kubeconfig) - return if cluster_id.nil? || cluster_name.nil? + def handle_delete_cluster(cluster_data) + cluster_id = cluster_data[:id] + cluster_name = cluster_data[:name] + kubeconfig = parse_kubeconfig(cluster_data[:kubeconfig]) exclude_kubeconfig(cluster_id, kubeconfig) if kubeconfig.present? params = { cluster_name: cluster_name, - oidc_token: ConfigFile.read_option(:oidc_token), + oidc_token: oidc_token, } - response = delete_cluster(ConfigFile.read_option(:server), project_slug, params) + response = delete_cluster(server, project_slug, params) if ResponseHelper.no_content?(response) Uffizzi.ui.say("Cluster #{cluster_name} deleted") @@ -180,19 +223,58 @@ def launch_demonise_skaffold(config_path) Uffizzi.process.daemon(true) at_exit do - File.delete(DevService.pid_path) if File.exist?(DevService.pid_path) + DevService.delete_pid end + DevService.save_pid File.delete(DevService.logs_path) if File.exist?(DevService.logs_path) - File.write(DevService.pid_path, Uffizzi.process.pid) DevService.start_check_pid_file_existence DevService.start_demonised_skaffold(config_path, options) rescue StandardError => e File.open(DevService.logs_path, 'a') { |f| f.puts(e.message) } end + def launch_basic_skaffold(config_path) + at_exit do + DevService.delete_pid + end + + DevService.save_pid + DevService.start_check_pid_file_existence + DevService.start_basic_skaffold(config_path, options) + end + + def fetch_dev_env_cluster! + if DevService.startup? + Uffizzi.ui.say_error_and_exit('Dev environment not started yet') + end + + cluster_name = dev_environment[:cluster_name] + ClusterService.fetch_cluster_data(cluster_name, **cluster_api_connection_params) + end + + def dev_environment + @dev_environment ||= DevService.dev_environment + end + + def cluster_api_connection_params + { + server: server, + project_slug: project_slug, + oidc_token: oidc_token, + } + end + def project_slug @project_slug ||= ConfigFile.read_option(:project) end + + def oidc_token + @oidc_token ||= ConfigFile.read_option(:oidc_token) + end + + def server + @server ||= ConfigFile.read_option(:server) + end end end diff --git a/lib/uffizzi/helpers/config_helper.rb b/lib/uffizzi/helpers/config_helper.rb index a1c3e35f..4788a5bb 100644 --- a/lib/uffizzi/helpers/config_helper.rb +++ b/lib/uffizzi/helpers/config_helper.rb @@ -52,6 +52,14 @@ def previous_current_context_by_path(path) cluster_previous_current_contexts.detect { |c| c[:kubeconfig_path] == path } end + def set_dev_environment(cluster_name, params = {}) + { cluster_name: cluster_name }.merge(params) + end + + def dev_environment + read_option_from_config(:dev_environment) || {} + end + private def clusters diff --git a/lib/uffizzi/services/cluster_service.rb b/lib/uffizzi/services/cluster_service.rb index 83f2aa66..4da7d307 100644 --- a/lib/uffizzi/services/cluster_service.rb +++ b/lib/uffizzi/services/cluster_service.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'uffizzi/response_helper' require 'uffizzi/clients/api/api_client' class ClusterService @@ -104,5 +105,28 @@ def valid_name?(name) regex = /\A[a-zA-Z0-9-]*\z/ regex.match?(name) end + + def fetch_cluster_data(cluster_name, server:, project_slug:, oidc_token:) + params = { + cluster_name: cluster_name, + oidc_token: oidc_token, + } + response = get_cluster(server, project_slug, params) + + if Uffizzi::ResponseHelper.ok?(response) + response.dig(:body, :cluster) + else + Uffizzi::ResponseHelper.handle_failed_response(response) + end + end + + def build_render_data(cluster_data) + { + name: cluster_data[:name], + status: cluster_data[:state], + created: Time.strptime(cluster_data[:created_at], '%Y-%m-%dT%H:%M:%S.%N').strftime('%a %b %d %H:%M:%S %Y'), + url: cluster_data[:host], + } + end end end diff --git a/lib/uffizzi/services/dev_service.rb b/lib/uffizzi/services/dev_service.rb index 57999660..b47558b5 100644 --- a/lib/uffizzi/services/dev_service.rb +++ b/lib/uffizzi/services/dev_service.rb @@ -3,21 +3,50 @@ require 'uffizzi/clients/api/api_client' class DevService + DEFAULT_REGISTRY_REPO = 'registry.uffizzi.com' + STARTUP_STATE = 'startup' + CLUSTER_DEPLOYED_STATE = 'cluster_deployed' + class << self include ApiClient - DEFAULT_REGISTRY_REPO = 'registry.uffizzi.com' + def check_no_running_process! + if process_running? + Uffizzi.ui.say_error_and_exit("You have already started uffizzi dev. To stop the process do 'uffizzi dev stop'") + end + end - def check_running_daemon - return unless File.exist?(pid_path) + def check_running_process! + unless process_running? + Uffizzi.ui.say_error_and_exit('Uffizzi dev is not running') + end + end - pid = File.read(pid_path) - File.delete(pid_path) if pid.blank? - Uffizzi.process.kill(0, pid.to_i) + def check_environment_exist! + if dev_environment.empty? + Uffizzi.ui.say_error_and_exit('Uffizzi dev does not exist') + end + end + + def stop_process + dev_pid = running_pid + skaffold_pid = running_skaffold_pid + + Uffizzi.process.kill('INT', skaffold_pid) + Uffizzi.process.kill('INT', dev_pid) + delete_pid + rescue Errno::ESRCH + delete_pid + end + + def process_running? + pid = running_pid + return false unless pid.positive? - Uffizzi.ui.say_error_and_exit("You have already started uffizzi dev as daemon. To stop the process do 'uffizzi dev stop'") + Uffizzi.process.kill(0, pid.to_i) + true rescue Errno::ESRCH - File.delete(pid_path) + false end def start_check_pid_file_existence @@ -34,6 +63,9 @@ def start_basic_skaffold(config_path, options) cmd = build_skaffold_dev_command(config_path, options) Uffizzi.ui.popen2e(cmd) do |_stdin, stdout_and_stderr, wait_thr| + pid = wait_thr.pid + skaffold_pid = find_skaffold_pid(pid) + save_skaffold_pid(skaffold_pid) stdout_and_stderr.each { |l| Uffizzi.ui.say(l) } wait_thr.value end @@ -44,6 +76,10 @@ def start_demonised_skaffold(config_path, options) cmd = build_skaffold_dev_command(config_path, options) Uffizzi.ui.popen2e(cmd) do |_stdin, stdout_and_stderr, wait_thr| + pid = wait_thr.pid + skaffold_pid = find_skaffold_pid(pid) + save_skaffold_pid(skaffold_pid) + File.open(logs_path, 'a') do |f| stdout_and_stderr.each do |line| f.puts(line) @@ -80,6 +116,10 @@ def pid_path File.join(Uffizzi::ConfigFile::CONFIG_DIR, 'uffizzi_dev.pid') end + def skaffold_pid_path + File.join(Uffizzi::ConfigFile::CONFIG_DIR, 'skaffold_dev.pid') + end + def logs_path File.join(Uffizzi::ConfigFile::CONFIG_DIR, 'uffizzi_dev.log') end @@ -104,5 +144,67 @@ def default_kubeconfig_path(kubeconfig_path) File.expand_path(path) end + + def running_pid + return nil.to_i unless File.exist?(pid_path) + + File.read(pid_path).to_i + end + + def save_pid + File.write(pid_path, Uffizzi.process.pid) + end + + def delete_pid + File.delete(pid_path) if File.exist?(pid_path) + File.delete(skaffold_pid_path) if File.exist?(skaffold_pid_path) + end + + def running_skaffold_pid + return nil.to_i unless File.exist?(skaffold_pid_path) + + File.read(skaffold_pid_path).to_i + end + + def save_skaffold_pid(pid) + File.write(skaffold_pid_path, pid) + end + + def set_dev_environment_config(cluster_name, config_path, options) + params = options.merge(config_path: File.expand_path(config_path)) + new_dev_environment = Uffizzi::ConfigHelper.set_dev_environment(cluster_name, params) + Uffizzi::ConfigFile.write_option(:dev_environment, new_dev_environment) + end + + def set_startup_state + new_dev_environment = dev_environment.merge(state: STARTUP_STATE) + Uffizzi::ConfigFile.write_option(:dev_environment, new_dev_environment) + end + + def set_cluster_deployed_state + new_dev_environment = dev_environment.merge(state: CLUSTER_DEPLOYED_STATE) + Uffizzi::ConfigFile.write_option(:dev_environment, new_dev_environment) + end + + def startup? + dev_environment[:state] == STARTUP_STATE + end + + def clear_dev_environment_config + Uffizzi::ConfigFile.write_option(:dev_environment, {}) + end + + def dev_environment + Uffizzi::ConfigHelper.dev_environment + end + + def find_skaffold_pid(ppid) + ppid_regex = /\w*\s+\d+\s+#{ppid}.*\sskaffold dev/ + pid_regex = /\w*\s+(\d+)\s+#{ppid}.*\sskaffold dev/ + + io = Uffizzi.ui.popen('ps -ef') + ps = io.readlines.detect { |l| l.match?(ppid_regex) } + ps.match(pid_regex)[1] + end end end diff --git a/lib/uffizzi/shell.rb b/lib/uffizzi/shell.rb index b4514407..82d672fc 100644 --- a/lib/uffizzi/shell.rb +++ b/lib/uffizzi/shell.rb @@ -11,6 +11,7 @@ class ExitError < Thor::Error; end PRETTY_JSON = 'pretty-json' REGULAR_JSON = 'json' + PRETTY_LIST = 'pretty-list' def initialize @shell = Thor::Shell::Basic.new @@ -55,6 +56,10 @@ def stdout_pipe? $stdout.stat.pipe? end + def popen(command) + IO.popen(command) + end + def popen2e(command, &block) Open3.popen2e(command, &block) end @@ -73,12 +78,25 @@ def format_to_pretty_json(data) JSON.pretty_generate(data) end + def format_to_pretty_list(data) + case data + when Array + data.map { |v| format_to_pretty_list(v) }.join("\n\n") + when Hash + data.map { |k, v| "- #{k.to_s.upcase}: #{v}" }.join("\n").strip + else + data + end + end + def format_message(message) case output_format when PRETTY_JSON format_to_pretty_json(message) when REGULAR_JSON format_to_json(message) + when PRETTY_LIST + format_to_pretty_list(message) else message end diff --git a/lib/uffizzi/version.rb b/lib/uffizzi/version.rb index c5511bab..f15b67b9 100644 --- a/lib/uffizzi/version.rb +++ b/lib/uffizzi/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Uffizzi - VERSION = '2.2.0' + VERSION = '2.2.1' end diff --git a/man/uffizzi b/man/uffizzi index f501e3d0..283a84c3 100644 --- a/man/uffizzi +++ b/man/uffizzi @@ -33,6 +33,9 @@ GROUP is one of the following: project Manage Uffizzi project resources including compose files for specifying compose environment (preview) configurations and secrets + + dev + Creates a Uffizzi cluster preconfigured for development workflows .fi .SH "COMMAND" .nf diff --git a/man/uffizzi-dev-describe b/man/uffizzi-dev-describe new file mode 100644 index 00000000..8287b3bf --- /dev/null +++ b/man/uffizzi-dev-describe @@ -0,0 +1,36 @@ +.\" generated with Ronn-NG/v0.9.1 +.\" http://github.com/apjanke/ronn-ng/tree/0.9.1 +.TH "UFFIZZI\-DEV\-DESCRIBE" "" "October 2023" "" +.SH "NAME" +\fBuffizzi\-dev\-describe\fR +.P +$ uffizzi dev describe \-h uffizzi\-dev\-describe \- show metadata for a dev environment ================================================================ +.SH "SYNOPSIS" +.nf +uffizzi dev describe +.fi +.SH "DESCRIPTION" +.nf +Shows metadata for a dev environment + +This command can fail for the following reasons: + \- The dev environment specified does not exist\. + \- The dev environment specified belongs to a different project\. + +For more information on Uffizzi clusters, see: +https://docs\.uffizzi\.com/references/cli/ +.fi +.SH "POSITIONAL ARGUMENTS" +.nf +[NAME] + NAME for the dev environment you want to describe\. + This is an optional argument\. +.fi +.SH "EXAMPLES" +.nf +The following command prints metadata for the dev +environment: + + $ uffizzi dev describe +.fi + diff --git a/man/uffizzi-dev-describe.ronn b/man/uffizzi-dev-describe.ronn new file mode 100644 index 00000000..1232e4f9 --- /dev/null +++ b/man/uffizzi-dev-describe.ronn @@ -0,0 +1,27 @@ +$ uffizzi dev describe -h +uffizzi-dev-describe - show metadata for a dev environment +================================================================ + +## SYNOPSIS + uffizzi dev describe + +## DESCRIPTION + Shows metadata for a dev environment + + This command can fail for the following reasons: + - The dev environment specified does not exist. + - The dev environment specified belongs to a different project. + + For more information on Uffizzi clusters, see: + https://docs.uffizzi.com/references/cli/ + +## POSITIONAL ARGUMENTS + [NAME] + NAME for the dev environment you want to describe. + This is an optional argument. + +## EXAMPLES + The following command prints metadata for the dev + environment: + + $ uffizzi dev describe diff --git a/man/uffizzi.ronn b/man/uffizzi.ronn index 305f5d9b..15f19305 100644 --- a/man/uffizzi.ronn +++ b/man/uffizzi.ronn @@ -29,6 +29,9 @@ uffizzi - manage Uffizzi resources Manage Uffizzi project resources including compose files for specifying compose environment (preview) configurations and secrets + dev + Creates a Uffizzi cluster preconfigured for development workflows + ## COMMAND COMMAND is one of the following: diff --git a/test/fixtures/files/uffizzi/process_list.txt b/test/fixtures/files/uffizzi/process_list.txt new file mode 100644 index 00000000..851c1a65 --- /dev/null +++ b/test/fixtures/files/uffizzi/process_list.txt @@ -0,0 +1,6 @@ +zipofar 4051120 1 0 окт18 pts/17 00:00:00 sh -c skaffold dev --filename='/home/zipofar/test/skaffold/examples/ruby2/skaffold_dev.yaml' --default-repo='registry.uffizzi.com' --kubeconfig='/home/zipofar/.kube/config' +zipofar 4051122 4051120 0 окт18 pts/17 00:00:11 skaffold dev --filename=/home/zipofar/test/skaffold/examples/ruby2/skaffold_dev.yaml --default-repo=registry.uffizzi.com --kubeconfig=/home/zipofar/.kube/config +zipofar 4068798 3369035 2 14:41 pts/17 00:00:01 uffizzi dev start /home/zipofar/test/skaffold/examples/ruby2/skaffold_dev.yaml +zipofar 4068842 4068798 0 14:41 pts/17 00:00:00 sh -c skaffold dev --filename='/home/zipofar/test/skaffold/examples/ruby2/skaffold_dev.yaml' --default-repo='registry.uffizzi.com' --kubeconfig='/home/zipofar/.kube/config' +zipofar 4068844 4068842 1 14:41 pts/17 00:00:00 skaffold dev --filename=/home/zipofar/test/skaffold/examples/ruby2/skaffold_dev.yaml --default-repo=registry.uffizzi.com --kubeconfig=/home/zipofar/.kube/config +zipofar 4069308 4029397 0 14:42 pts/12 00:00:00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --exclude-dir=.idea --exclude-dir=.tox skaff diff --git a/test/support/fixture_support.rb b/test/support/fixture_support.rb index 668a2894..05dd28f4 100644 --- a/test/support/fixture_support.rb +++ b/test/support/fixture_support.rb @@ -2,12 +2,15 @@ module FixtureSupport def file_fixture(file_path) - full_path = File.join(Dir.pwd, 'test', 'fixtures', file_path) - File.new(full_path) + File.new(full_path_fixture(file_path)) end def json_fixture(file_path, symbolize_names: true) data = file_fixture(file_path).read JSON.parse(data, symbolize_names: symbolize_names) end + + def full_path_fixture(file_path) + File.join(Dir.pwd, 'test', 'fixtures', file_path) + end end diff --git a/test/support/mocks/mock_process.rb b/test/support/mocks/mock_process.rb index 87161260..48de6320 100644 --- a/test/support/mocks/mock_process.rb +++ b/test/support/mocks/mock_process.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class MockProcess + SINGALS = [0, 'QUIT', 'INT'].freeze + attr_accessor :pid def initialize @@ -8,7 +10,7 @@ def initialize end def kill(sig, pid) - return @pid if sig.zero? && pid == @pid + return @pid if SINGALS.include?(sig) && pid == @pid raise Errno::ESRCH if pid != @pid @pid = nil diff --git a/test/support/mocks/mock_prompt.rb b/test/support/mocks/mock_prompt.rb index 3435cc55..1051317d 100644 --- a/test/support/mocks/mock_prompt.rb +++ b/test/support/mocks/mock_prompt.rb @@ -37,7 +37,16 @@ def promise_question_answer(question, answer) private def get_answer(question) - answer_index = @question_answers.index { |question_answer| question_answer[:question] == question } + answer_index = @question_answers.index do |question_answer| + question_answer[:question] == question + case question_answer[:question] + when Regexp + question_answer[:question].match?(question) + else + question_answer[:question] == question + end + end + answer = @question_answers[answer_index].fetch(:answer) @question_answers.delete_at(answer_index) diff --git a/test/support/mocks/mock_shell.rb b/test/support/mocks/mock_shell.rb index 57fa3032..ce1cf024 100644 --- a/test/support/mocks/mock_shell.rb +++ b/test/support/mocks/mock_shell.rb @@ -14,17 +14,27 @@ def success? end class MockProcessWaiter + def initialize(params = {}) + @pid = params[:pid] + end + def value MockProcessStatus.new(true) end + + def pid + @pid || generate_pid + end + + private + + def generate_pid + (Time.now.utc.to_f * 100_000).to_i + end end attr_accessor :messages, :output_format, :stdout_pipe - PRETTY_JSON = 'pretty-json' - REGULAR_JSON = 'json' - GITHUB_ACTION = 'github-action' - def initialize @messages = [] @command_responses = [] @@ -74,22 +84,27 @@ def enable_stdout @output_enabled = true end + def popen(command) + res = get_command_response(command) + res[:stdout] + end + def popen2e(command) - stdout, stderr = get_command_response(command) - stdout_and_stderr = [stdout, stderr] - process_waiter = MockProcessWaiter.new + res = get_command_response(command) + stdout_and_stderr = [res[:stdout], res[:stderr]] + process_waiter = MockProcessWaiter.new(res[:waiter]) block_given? ? yield(nil, stdout_and_stderr, process_waiter) : [nil, stdout_and_stderr, process_waiter] end def capture3(command, *_params) - stdout, stderr = get_command_response(command) - status = MockProcessStatus.new(stderr.nil?) + res = get_command_response(command) + status = MockProcessStatus.new(res[:stderr].nil?) - [stdout, stderr, status] + [res[:stdout], res[:stderr], status] end - def promise_execute(command, stdout: nil, stderr: nil) - @command_responses << { command: command, stdout: stdout, stderr: stderr } + def promise_execute(command, stdout: nil, stderr: nil, waiter: nil) + @command_responses << { command: command, stdout: stdout, stderr: stderr, waiter: waiter } end private @@ -112,12 +127,25 @@ def format_to_github_action(data) end end + def format_to_pretty_list(data) + case data + when Array + data.map { |v| format_to_pretty_list(v) }.join("\n\n") + when Hash + data.map { |k, v| "- #{k.to_s.upcase}: #{v}" }.join("\n").strip + else + data + end + end + def format_message(message) case output_format - when PRETTY_JSON + when Uffizzi::UI::Shell::PRETTY_JSON format_to_pretty_json(message) - when REGULAR_JSON + when Uffizzi::UI::Shell::REGULAR_JSON format_to_json(message) + when Uffizzi::UI::Shell::PRETTY_LIST + format_to_pretty_list(message) else message end @@ -135,8 +163,9 @@ def get_command_response(command) stdout = @command_responses[response_index].fetch(:stdout) stderr = @command_responses[response_index].fetch(:stderr) + waiter = @command_responses[response_index].fetch(:waiter) @command_responses.delete_at(response_index) - [stdout, stderr] + { stdout: stdout, stderr: stderr, waiter: waiter } end end diff --git a/test/test_helper.rb b/test/test_helper.rb index c7e85845..6e1b81b7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -34,8 +34,10 @@ class Minitest::Test TEST_CONFIG_PATH = 'tmp/config_default.json' + TEST_CONFIG_DIR = 'tmp/config_default.json' TEST_TOKEN_PATH = 'tmp/token_default.json' TEST_PID_PATH = 'tmp/dev.pid' + TEST_PID_PATH = 'tmp/skaffold_dev.pid' TEST_DEV_LOGS_PATH = 'tmp/dev-logs.txt' def before_setup @@ -51,6 +53,7 @@ def before_setup Uffizzi::Token.stubs(:token_path).returns(TEST_TOKEN_PATH) Uffizzi::ConfigFile.stubs(:config_path).returns(TEST_CONFIG_PATH) DevService.stubs(:pid_path).returns(TEST_PID_PATH) + DevService.stubs(:skaffold_pid_path).returns(TEST_PID_PATH) DevService.stubs(:logs_path).returns(TEST_DEV_LOGS_PATH) end diff --git a/test/uffizzi/cli/dev_test.rb b/test/uffizzi/cli/dev_test.rb index d4153ed2..52ad292d 100644 --- a/test/uffizzi/cli/dev_test.rb +++ b/test/uffizzi/cli/dev_test.rb @@ -25,19 +25,20 @@ def test_start_dev cluster_get_body = json_fixture('files/uffizzi/uffizzi_cluster_deployed.json') stubbed_uffizzi_cluster_create_request = stub_uffizzi_create_cluster(cluster_create_body, @project_slug) stubbed_uffizzi_cluster_get_request = stub_get_cluster_request(cluster_get_body, @project_slug) - stubbed_uffizzi_cluster_delete_request = stub_uffizzi_delete_cluster(@project_slug) @mock_shell.promise_execute(/skaffold version/, stdout: 'v.2.7.1') - @mock_shell.promise_execute(/skaffold dev --filename/, stdout: 'Good') + @mock_shell.promise_execute(/ps -ef/, stdout: File.open(full_path_fixture('files/uffizzi/process_list.txt'))) + @mock_shell.promise_execute(/skaffold dev --filename/, stdout: [], waiter: { pid: 4068842 }) + @dev.options = command_options(kubeconfig: @kubeconfig_path) @dev.start(@skaffold_file_path) cluster_from_config = Uffizzi::ConfigFile.read_option(:clusters) + dev_environment_from_config = Uffizzi::ConfigFile.read_option(:dev_environment) - assert_match('deleted', Uffizzi.ui.last_message) - assert_nil(cluster_from_config) + assert_equal(@kubeconfig_path, cluster_from_config.first[:kubeconfig_path]) + assert_equal(DevService::CLUSTER_DEPLOYED_STATE, dev_environment_from_config[:state]) assert_requested(stubbed_uffizzi_cluster_create_request) assert_requested(stubbed_uffizzi_cluster_get_request) - assert_requested(stubbed_uffizzi_cluster_delete_request) end def test_start_dev_with_existed_current_context @@ -45,7 +46,6 @@ def test_start_dev_with_existed_current_context cluster_get_body = json_fixture('files/uffizzi/uffizzi_cluster_deployed.json') stubbed_uffizzi_cluster_create_request = stub_uffizzi_create_cluster(cluster_create_body, @project_slug) stubbed_uffizzi_cluster_get_request = stub_get_cluster_request(cluster_get_body, @project_slug) - stubbed_uffizzi_cluster_delete_request = stub_uffizzi_delete_cluster(@project_slug) existing_kubeconfig = Psych.safe_load(Base64.decode64(cluster_get_body.dig(:cluster, :kubeconfig))).deep_dup existing_kubeconfig['users'][0]['name'] = 'another-user-name' @@ -59,19 +59,21 @@ def test_start_dev_with_existed_current_context File.write(@kubeconfig_path, existing_kubeconfig.to_yaml) @mock_shell.promise_execute(/skaffold version/, stdout: 'v.2.7.1') - @mock_shell.promise_execute(/skaffold dev --filename/, stdout: 'Good') + @mock_shell.promise_execute(/ps -ef/, stdout: File.open(full_path_fixture('files/uffizzi/process_list.txt'))) + @mock_shell.promise_execute(/skaffold dev --filename/, stdout: [], waiter: { pid: 4068842 }) + @dev.options = command_options(kubeconfig: @kubeconfig_path) @dev.start(@skaffold_file_path) cluster_from_config = Uffizzi::ConfigFile.read_option(:clusters) + dev_environment_from_config = Uffizzi::ConfigFile.read_option(:dev_environment) current_kubeconfig = Psych.safe_load(File.read(@kubeconfig_path)) - assert_match('deleted', Uffizzi.ui.last_message) - assert_nil(cluster_from_config) - assert_equal(existing_kubeconfig['current-context'], current_kubeconfig['current-context']) + assert_equal(@kubeconfig_path, cluster_from_config.first[:kubeconfig_path]) + assert_equal(DevService::CLUSTER_DEPLOYED_STATE, dev_environment_from_config[:state]) + refute_equal(existing_kubeconfig['current-context'], current_kubeconfig['current-context']) assert_requested(stubbed_uffizzi_cluster_create_request) assert_requested(stubbed_uffizzi_cluster_get_request) - assert_requested(stubbed_uffizzi_cluster_delete_request) end def test_start_dev_as_daemon @@ -79,30 +81,33 @@ def test_start_dev_as_daemon cluster_get_body = json_fixture('files/uffizzi/uffizzi_cluster_deployed.json') stubbed_uffizzi_cluster_create_request = stub_uffizzi_create_cluster(cluster_create_body, @project_slug) stubbed_uffizzi_cluster_get_request = stub_get_cluster_request(cluster_get_body, @project_slug) - stubbed_uffizzi_cluster_delete_request = stub_uffizzi_delete_cluster(@project_slug) @mock_shell.promise_execute(/skaffold version/, stdout: 'v.2.7.1') - @mock_shell.promise_execute(/skaffold dev --filename/, stdout: 'Good') + @mock_shell.promise_execute(/ps -ef/, stdout: File.open(full_path_fixture('files/uffizzi/process_list.txt'))) + @mock_shell.promise_execute(/skaffold dev --filename/, stdout: [], waiter: { pid: 4068842 }) @dev.options = command_options(quiet: true) + @dev.options = command_options(kubeconfig: @kubeconfig_path) @dev.start(@skaffold_file_path) cluster_from_config = Uffizzi::ConfigFile.read_option(:clusters) + dev_environment_from_config = Uffizzi::ConfigFile.read_option(:dev_environment) - assert_match('deleted', Uffizzi.ui.last_message) - assert_nil(cluster_from_config) + assert_equal(@kubeconfig_path, cluster_from_config.first[:kubeconfig_path]) + assert_equal(DevService::CLUSTER_DEPLOYED_STATE, dev_environment_from_config[:state]) assert_requested(stubbed_uffizzi_cluster_create_request) assert_requested(stubbed_uffizzi_cluster_get_request) - assert_requested(stubbed_uffizzi_cluster_delete_request) end def test_start_dev_as_daemon_when_deamon_already_run @mock_shell.promise_execute(/skaffold version/, stdout: 'v.2.7.1') - @mock_shell.promise_execute(/skaffold dev --filename/, stdout: 'Good') @dev.options = command_options(quiet: true) + dev_environment = { state: DevService::CLUSTER_DEPLOYED_STATE } + Uffizzi::ConfigFile.write_option(:dev_environment, dev_environment) File.write(DevService.pid_path, '1000') @mock_process.pid = 1000 error = assert_raises(MockShell::ExitError) do + @dev.options = command_options(kubeconfig: @kubeconfig_path) @dev.start(@skaffold_file_path) end @@ -114,10 +119,11 @@ def test_start_dev_without_skaffold_config @mock_shell.promise_execute(/skaffold version/, stdout: 'v.2.7.1') error = assert_raises(MockShell::ExitError) do + @dev.options = command_options(kubeconfig: @kubeconfig_path) @dev.start(@skaffold_file_path) end - assert_match('Please provide a valid config', error.message) + assert_match('A valid dev environment configuration is required', error.message) end def test_start_dev_with_kubeconfig_and_default_repo_flags @@ -127,20 +133,96 @@ def test_start_dev_with_kubeconfig_and_default_repo_flags cluster_get_body = json_fixture('files/uffizzi/uffizzi_cluster_deployed.json') stubbed_uffizzi_cluster_create_request = stub_uffizzi_create_cluster(cluster_create_body, @project_slug) stubbed_uffizzi_cluster_get_request = stub_get_cluster_request(cluster_get_body, @project_slug) - stubbed_uffizzi_cluster_delete_request = stub_uffizzi_delete_cluster(@project_slug) - @mock_shell.promise_execute(/skaffold version/, stdout: 'v.2.7.1') skaffold_dev_regex = /skaffold dev --filename='.*' --default-repo='#{default_repo}' --kubeconfig='#{kubeconfig_path}'/ - @mock_shell.promise_execute(skaffold_dev_regex, stdout: 'Good') + + @mock_shell.promise_execute(/skaffold version/, stdout: 'v.2.7.1') + @mock_shell.promise_execute(skaffold_dev_regex, stdout: [], waiter: { pid: 4068842 }) + @mock_shell.promise_execute(/ps -ef/, stdout: File.open(full_path_fixture('files/uffizzi/process_list.txt'))) @dev.options = command_options('default-repo': default_repo, kubeconfig: kubeconfig_path) @dev.start(@skaffold_file_path) cluster_from_config = Uffizzi::ConfigFile.read_option(:clusters) - assert_match('deleted', Uffizzi.ui.last_message) - assert_nil(cluster_from_config) + assert_equal(kubeconfig_path, cluster_from_config.first[:kubeconfig_path]) assert_requested(stubbed_uffizzi_cluster_create_request) assert_requested(stubbed_uffizzi_cluster_get_request) + end + + def test_describe_single_dev + cluster_get_body = json_fixture('files/uffizzi/uffizzi_cluster_deployed.json') + stubbed_uffizzi_cluster_get_request = stub_get_cluster_request(cluster_get_body, @project_slug) + + config_path = '/skaffold.yaml' + cluster_name = cluster_get_body.dig(:cluster, :name) + dev_environment = { cluster_name: cluster_name, config_path: config_path } + Uffizzi::ConfigFile.write_option(:dev_environment, dev_environment) + File.write(DevService.pid_path, @mock_process.pid) + + @dev.describe + + assert_match("- CONFIG_PATH: #{config_path}", Uffizzi.ui.last_message) + assert_match("- NAME: #{cluster_name}", Uffizzi.ui.last_message) + assert_requested(stubbed_uffizzi_cluster_get_request) + end + + def test_describe_zero_dev + error = assert_raises(MockShell::ExitError) do + @dev.describe + end + + assert_match('Uffizzi dev does not exist', error.message) + end + + def test_stop_when_dev_exist + config_path = '/skaffold.yaml' + cluster_name = 'my-cluster' + dev_environment = { cluster_name: cluster_name, config_path: config_path } + Uffizzi::ConfigFile.write_option(:dev_environment, dev_environment) + File.write(DevService.pid_path, @mock_process.pid) + + @dev.stop + + dev_environment_from_config = Uffizzi::ConfigFile.read_option(:dev_environment) + + assert_match('Uffizzi dev was stopped', Uffizzi.ui.last_message) + assert_match(cluster_name, dev_environment_from_config[:cluster_name]) + end + + def test_stop_when_dev_not_exist + error = assert_raises(MockShell::ExitError) do + @dev.stop + end + + assert_match('Uffizzi dev is not running', error.message) + end + + def test_delete_when_dev_exist + cluster_get_body = json_fixture('files/uffizzi/uffizzi_cluster_deployed.json') + stubbed_uffizzi_cluster_get_request = stub_get_cluster_request(cluster_get_body, @project_slug) + stubbed_uffizzi_cluster_delete_request = stub_uffizzi_delete_cluster(@project_slug) + + config_path = '/skaffold.yaml' + cluster_name = cluster_get_body.dig(:cluster, :name) + dev_environment = { cluster_name: cluster_name, config_path: config_path } + Uffizzi::ConfigFile.write_option(:dev_environment, dev_environment) + File.write(DevService.pid_path, @mock_process.pid) + + @dev.delete + + dev_environment_from_config = Uffizzi::ConfigFile.read_option(:dev_environment) + + assert_match("Cluster #{cluster_name} deleted", Uffizzi.ui.last_message) + assert(true, dev_environment_from_config.present?) + assert_requested(stubbed_uffizzi_cluster_get_request) assert_requested(stubbed_uffizzi_cluster_delete_request) end + + def test_delete_when_dev_not_exist + error = assert_raises(MockShell::ExitError) do + @dev.delete + end + + assert_match('Uffizzi dev does not exist', error.message) + end end