From 369423b74ec9b1b3d770859c6a3bf7d82c8ad56f Mon Sep 17 00:00:00 2001 From: ArnauGabrielAtienza Date: Thu, 9 Jan 2025 09:53:19 +0100 Subject: [PATCH 01/12] Add specific testing Signed-off-by: ArnauGabrielAtienza --- .../community-apps/appliances/lib/common.sh | 181 +++++ .../lib/community/ansible/inventory.yaml | 32 + .../appliances/lib/community/app_handler.rb | 93 +++ .../appliances/lib/community/app_readiness.rb | 42 + .../lib/community/clitester/CLITester.rb | 505 ++++++++++++ .../lib/community/clitester/DiskResize.rb | 59 ++ .../lib/community/clitester/OneFlowService.rb | 40 + .../lib/community/clitester/SafeExec.rb | 102 +++ .../lib/community/clitester/TempTemplate.rb | 18 + .../lib/community/clitester/TemplateParser.rb | 141 ++++ .../appliances/lib/community/clitester/VM.rb | 724 ++++++++++++++++++ .../lib/community/clitester/VMTemplate.rb | 36 + .../appliances/lib/community/clitester/VN.rb | 66 ++ .../lib/community/clitester/VNetOVS.rb | 90 +++ .../lib/community/clitester/VNetVLAN.rb | 80 ++ .../lib/community/clitester/datastore.rb | 112 +++ .../lib/community/clitester/host.rb | 121 +++ .../lib/community/clitester/image.rb | 134 ++++ .../lib/community/clitester/init.rb | 84 ++ .../lib/community/clitester/one-open-uri.rb | 13 + .../lib/community/clitester/oneobject.rb | 127 +++ .../community/clitester/opennebula_test.rb | 631 +++++++++++++++ .../appliances/lib/community/defaults.yaml | 20 + .../appliances/lib/community/tests.md | 200 +++++ .../appliances/lib/functions.sh | 406 ++++++++++ .../community-apps/appliances/lib/helpers.rb | 268 +++++++ .../community-apps/appliances/lib/tests.rb | 229 ++++++ .../community-apps/appliances/lib/tests.sh | 7 + 28 files changed, 4561 insertions(+) create mode 100644 apps-code/community-apps/appliances/lib/common.sh create mode 100644 apps-code/community-apps/appliances/lib/community/ansible/inventory.yaml create mode 100644 apps-code/community-apps/appliances/lib/community/app_handler.rb create mode 100755 apps-code/community-apps/appliances/lib/community/app_readiness.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/CLITester.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/DiskResize.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/OneFlowService.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/SafeExec.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/TempTemplate.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/TemplateParser.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/VM.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/VMTemplate.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/VN.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/VNetOVS.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/VNetVLAN.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/datastore.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/host.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/image.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/init.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/one-open-uri.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/oneobject.rb create mode 100644 apps-code/community-apps/appliances/lib/community/clitester/opennebula_test.rb create mode 100644 apps-code/community-apps/appliances/lib/community/defaults.yaml create mode 100644 apps-code/community-apps/appliances/lib/community/tests.md create mode 100644 apps-code/community-apps/appliances/lib/functions.sh create mode 100644 apps-code/community-apps/appliances/lib/helpers.rb create mode 100644 apps-code/community-apps/appliances/lib/tests.rb create mode 100755 apps-code/community-apps/appliances/lib/tests.sh diff --git a/apps-code/community-apps/appliances/lib/common.sh b/apps-code/community-apps/appliances/lib/common.sh new file mode 100644 index 0000000..f538c75 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/common.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash + +# ---------------------------------------------------------------------------- # +# Copyright 2018-2019, OpenNebula Project, OpenNebula Systems # +# # +# Licensed under the Apache License, Version 2.0 (the "License"); you may # +# not use this file except in compliance with the License. You may obtain # +# a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# ---------------------------------------------------------------------------- # + + +# shellcheck disable=SC2086 +true + + +# args: +msg() +{ + msg_type="$1" + shift + + case "$msg_type" in + info) + printf "[%s] => " "$(date)" + echo 'INFO:' "$@" + ;; + debug) + printf "[%s] => " "$(date)" >&2 + echo 'DEBUG:' "$@" >&2 + ;; + warning) + printf "[%s] => " "$(date)" >&2 + echo 'WARNING [!]:' "$@" >&2 + ;; + error) + printf "[%s] => " "$(date)" >&2 + echo 'ERROR [!!]:' "$@" >&2 + return 1 + ;; + *) + printf "[%s] => " "$(date)" >&2 + echo 'UNKNOWN [?!]:' "$@" >&2 + return 2 + ;; + esac + return 0 +} + +# arg: +gen_password() +{ + pw_length="${1:-16}" + new_pw='' + + while true ; do + if command -v pwgen >/dev/null ; then + new_pw=$(pwgen -s "${pw_length}" 1) + break + elif command -v openssl >/dev/null ; then + new_pw="${new_pw}$(openssl rand -base64 ${pw_length} | tr -dc '[:alnum:]')" + else + new_pw="${new_pw}$(head /dev/urandom | tr -dc '[:alnum:]')" + fi + # shellcheck disable=SC2000 + [ "$(echo $new_pw | wc -c)" -ge "$pw_length" ] && break + done + + echo "$new_pw" | cut -c1-${pw_length} +} + +get_local_ip() +{ + extif=$(ip r | awk '{if ($1 == "default") print $5;}') + local_ip=$(ip a show dev "$extif" | \ + awk '{if ($1 == "inet") print $2;}' | sed -e '/^127\./d' -e 's#/.*##') + + echo "${local_ip:-127.0.0.1}" +} + +# show default help based on the ONE_SERVICE_PARAMS +# service_help in appliance.sh may override this function +default_service_help() +{ + echo "USAGE: " + + for _command in 'help' 'install' 'configure' 'bootstrap'; do + echo " $(basename "$0") ${_command}" + + case "${_command}" in + help) echo ' Prints this help' ;; + install) echo ' Installs service' ;; + configure) echo ' Configures service via contextualization or defaults' ;; + bootstrap) echo ' Bootstraps service via contextualization' ;; + esac + + local _index=0 + while [ -n "${ONE_SERVICE_PARAMS[${_index}]}" ]; do + local _name="${ONE_SERVICE_PARAMS[${_index}]}" + local _type="${ONE_SERVICE_PARAMS[$((_index + 1))]}" + local _desc="${ONE_SERVICE_PARAMS[$((_index + 2))]}" + local _input="${ONE_SERVICE_PARAMS[$((_index + 3))]}" + _index=$((_index + 4)) + + if [ "${_command}" = "${_type}" ]; then + if [ -z "${_input}" ]; then + echo -n ' ' + else + echo -n ' * ' + fi + + printf "%-25s - %s\n" "${_name}" "${_desc}" + fi + done + + echo + done + + echo 'Note: (*) variables are provided to the user via USER_INPUTS' +} + +#TODO: more or less duplicate to common.sh/service_help() +params2md() +{ + local _command=$1 + + local _index=0 + local _count=0 + while [ -n "${ONE_SERVICE_PARAMS[${_index}]}" ]; do + local _name="${ONE_SERVICE_PARAMS[${_index}]}" + local _type="${ONE_SERVICE_PARAMS[$((_index + 1))]}" + local _desc="${ONE_SERVICE_PARAMS[$((_index + 2))]}" + local _input="${ONE_SERVICE_PARAMS[$((_index + 3))]}" + _index=$((_index + 4)) + + if [ "${_command}" = "${_type}" ] && [ -n "${_input}" ]; then + # shellcheck disable=SC2016 + printf '* `%s` - %s\n' "${_name}" "${_desc}" + _count=$((_count + 1)) + fi + done + + if [ "${_count}" -eq 0 ]; then + echo '* none' + fi +} + +create_one_service_metadata() +{ + # shellcheck disable=SC2001 + cat >"${ONE_SERVICE_METADATA}" < +is_true() +{ + _value=$(eval echo "\$${1}" | tr '[:upper:]' '[:lower:]') + case "$_value" in + 1|true|yes|y) + return 0 + ;; + esac + + return 1 +} \ No newline at end of file diff --git a/apps-code/community-apps/appliances/lib/community/ansible/inventory.yaml b/apps-code/community-apps/appliances/lib/community/ansible/inventory.yaml new file mode 100644 index 0000000..5462b74 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/ansible/inventory.yaml @@ -0,0 +1,32 @@ +--- +all: + vars: + ansible_user: root + one_version: '6.10' + one_pass: opennebula + unattend_disable: true + ds: + mode: ssh + vn: + admin_net: + managed: true + template: + VN_MAD: bridge + PHYDEV: eth0 + BRIDGE: br0 + AR: + TYPE: IP4 + IP: 172.20.0.100 + SIZE: 48 + NETWORK_ADDRESS: 172.20.0.0 + NETWORK_MASK: 255.255.255.0 + GATEWAY: 172.20.0.1 + DNS: 1.1.1.1 + +frontend: + hosts: + f1: { ansible_host: 172.20.0.3 } # replace with the machine that will run opennebula + +node: + hosts: + n1: { ansible_host: 172.20.0.3 } # # replace with the machine that will run opennebula diff --git a/apps-code/community-apps/appliances/lib/community/app_handler.rb b/apps-code/community-apps/appliances/lib/community/app_handler.rb new file mode 100644 index 0000000..a63f79c --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/app_handler.rb @@ -0,0 +1,93 @@ +require 'rspec' +require 'yaml' + +$LOAD_PATH << "#{Dir.pwd}/clitester" + +require 'init' # Load CLI libraries. These issue opennebula commands to mimic admin behavior +require 'image' + +config = YAML.load_file("#{File.dirname(caller_locations.first.absolute_path)}/../metadata.yaml") + +VM_TEMPLATE = config[:one][:template][:NAME] || 'base' +IMAGE_DATASTORE = config[:one][:datastore] || 'default' + +APPS_PATH = config[:infra][:apps_path] || '/opt/one-apps/export' +DISK_FORMAT = config[:infra][:disk_format] || 'qcow2' + +APP_IMAGE_NAME = config[:app][:name] +APP_CONTEXT_PARAMS = config[:app][:context][:params] + +ENV['ONE_XMLRPC_TIMEOUT'] = config[:one][:timeout] || '90' + +RSpec.shared_context 'vm_handler' do + before(:all) do + @info = {} # Used to pass info across tests + + if !CLIImage.list('-l NAME').include?(APP_IMAGE_NAME) + + path = "#{APPS_PATH}/#{APP_IMAGE_NAME}.#{DISK_FORMAT}" + + CLIImage.create(APP_IMAGE_NAME, IMAGE_DATASTORE, "--path #{path}") + end + + prefixed = config[:app][:context][:prefixed] + + options = "--context #{app_context(APP_CONTEXT_PARAMS, prefixed)} --disk #{APP_IMAGE_NAME}" + + # Create a new VM by issuing onetemplate instantiate VM_TEMPLATE + @info[:vm] = VM.instantiate(VM_TEMPLATE, true, options) + @info[:vm].info + end + + after(:all) do + generate_context(config) + @info[:vm].terminate_hard + end +end + +# +# Generate context section for app testing based on app input +# +# @param [Hash] app_context_params CONTEXT section parameters +# @param [Bool] prefixed Custom context parameters have been prefixed with ONEAPP_ on the app logic +# +# @return [String] Comma separated list of context parameters ready to be used with --context on CLI template instantiation +# +def app_context(app_context_params, prefixed = true) + params = [%(SSH_PUBLIC_KEY=\\"\\$USER[SSH_PUBLIC_KEY]\\"), 'NETWORK="YES"'] + + prefixed == true ? prefix = 'ONEAPP_' : prefix = '' + + app_context_params.each do |key, value| + params << "#{prefix}#{key}=\"#{value}\"" + end + + return params.join(',') +end + +# +# Generates tests section for defaults.yaml file for context-kvm input +# +# @param [Hash] metadata App Metadata stated in metadata.yaml +# +def generate_context(metadata) + name = metadata[:app][:name] + + context_input = <<~EOT + --- + :tests: + '#{metadata[:app][:os][:base]}': + :image_name: #{name}.#{metadata[:infra][:disk_format]} + :type: #{metadata[:app][:os][:type]} + :microenvs: ['context-#{metadata[:app][:hypervisor].downcase}'] + :slow: true + :enable_netcfg_common: True + :enable_netcfg_ip_methods: True + + EOT + + short_name = metadata[:app][:name].split('_').last + context_file_path = "#{Dir.pwd}/../../#{short_name}/context.yaml" + + File.write(context_file_path, context_input) +end diff --git a/apps-code/community-apps/appliances/lib/community/app_readiness.rb b/apps-code/community-apps/appliances/lib/community/app_readiness.rb new file mode 100755 index 0000000..4a3e61a --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/app_readiness.rb @@ -0,0 +1,42 @@ +#!/usr/bin/env ruby + +ENV['RUBYOPT'] = "-W0" + +require 'yaml' +require 'fileutils' + +if ARGV.empty? + STDERR.puts 'Usage: ./app_readiness.rb ' + exit(1) +end + +app = ARGV[0] # 'example' + +tests_list_path = "../../#{app}/tests.yaml" +tests_path = "../../#{app}/tests" + +if !File.exist? tests_list_path + STDERR.puts "Missing test file #{tests_list_path}" + exit(1) +end + +tests_list = YAML.load_file tests_list_path + +rspec_command = [ + 'rspec', + '-f d', + "-f h -o 'results/results.html'", + "-f d -o 'results/results.txt'", + "-f j -o 'results/results.json'" +] + +tests_list.each do |test| + rspec_command << "#{tests_path}/#{test}" +end + +system(rspec_command.join(' ')) + +# Fail gracefully if exitstatus is nil (ie on OOM kill) +rc = !$?.exitstatus.nil? ? $?.exitstatus : -1 + +exit rc diff --git a/apps-code/community-apps/appliances/lib/community/clitester/CLITester.rb b/apps-code/community-apps/appliances/lib/community/clitester/CLITester.rb new file mode 100644 index 0000000..fb08ee8 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/CLITester.rb @@ -0,0 +1,505 @@ +module CLITester + + # methods + + require 'json' + require 'tempfile' + require 'timeout' + require 'open3' + + ONE_LOCATION = ENV['ONE_LOCATION'] unless defined?(ONE_LOCATION) + + if !ONE_LOCATION + ONE_VAR_LOCATION = '/var/lib/one' unless defined?(ONE_VAR_LOCATION) + ONE_LOG_LOCATION = '/var/log/one' unless defined?(ONE_LOG_LOCATION) + else + ONE_VAR_LOCATION = ONE_LOCATION + '/var' unless defined?(ONE_VAR_LOCATION) + ONE_LOG_LOCATION = ONE_VAR_LOCATION unless defined?(ONE_LOG_LOCATION) + end + + DEFAULT_TIMEOUT = 180 + DEFAULT_EXEC_TIMEOUT = 200 + + DO_FSCK = true + + VM_SSH_OPTS='-o StrictHostKeyChecking=no ' << + '-o UserKnownHostsFile=/dev/null ' << + '-o ConnectTimeout=90 ' << + '-o BatchMode=yes ' << + '-o PasswordAuthentication=no ' << + '-o ServerAliveInterval=3 ' << + '-o ControlMaster=auto ' << + '-o ControlPersist=15 ' << + '-o ControlPath=~/.ssh-rspec-%C ' + + HOST_SSH_OPTS='-o PasswordAuthentication=no' + + DEFAULT_PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' + + REMOTE_FRONTEND = ENV['REMOTE_FRONTEND'] unless defined?(REMOTE_FRONTEND) + + if REMOTE_FRONTEND + m = REMOTE_FRONTEND.match(%r{^(?docker|ssh)://(?.*)}) + raise 'Malformed REMOTE_FRONTEND' if m.nil? + + case m[:proto] + when 'docker' + exec = "docker exec -u oneadmin -i #{m[:frontend_name]}" + + o, e, s = Open3.capture3("#{exec} cat /etc/profile.d/opennebula.sh") + if s.success? + env = Tempfile.new('docker-env') + env << o.gsub(/^export /, '').gsub(/=['"]/, '=').gsub(/['"]$/, '') + env.close + + # "persist" the temporary file + FileUtils.cp(env.path, "#{env.path}.env") + + exec = "docker exec -u oneadmin -i --env-file=#{env.path}.env #{m[:frontend_name]}" + end + + REMOTE_TYPE = 'docker' + REMOTE_EXEC = exec + REMOTE_COPY = :docker_copy + when 'ssh' + raise 'Not yet implemented REMOTE_FRONTEND protocol: ssh' + else + raise "Not supported REMOTE_FRONTEND protocol: #{m[:proto]}" + end + end + + def docker_copy(path) + SafeExec.run("tar -C $(dirname #{path}) $(basename #{path}) -cf - " + + "| #{REMOTE_EXEC} tar -C $(dirname #{path}) -xf -") + end + + def pre_cli_action_docker(action_string) + # Detect local files and copy them to frontend container + action_string.split[1..-1].each do |arg| + file = arg.strip + + next unless File.exist?(file) + + cmd = SafeExec.run("#{REMOTE_EXEC} stat #{file} 2>/dev/null") + + # We don't copy files, which already exist on remote + # (e.g., it could replace remote datastores with local) + method(REMOTE_COPY).call(file) if cmd.fail? + end + end + + def pre_cli_action(action_string) + if REMOTE_FRONTEND && REMOTE_TYPE == 'docker' + pre_cli_action_docker(action_string) + action_string = %(#{REMOTE_EXEC} #{action_string}) + end + + action_string + end + + def cli_action(action_string, expected_result = true, may_fail = false) + action_string = pre_cli_action(action_string) + + cmd = SafeExec.run(action_string) + if cmd.fail? && !may_fail + puts cmd.stdout + puts cmd.stderr + end + + if !expected_result.nil? + expect(cmd.success?).to be(expected_result), + "This command didn't #{expected_result ? 'succeed' : 'fail'} as expected:\n"<< + " #{action_string}\n" << + "#{cmd.stdout.nil? ? '' : ' ' + cmd.stdout}"<< + "#{cmd.stderr.nil? ? '' : ' ' + cmd.stderr}" + end + + cmd + end + + def cli_action_timeout(action_string, expected_result = true, timeout = nil) + action_string = pre_cli_action(action_string) + + if !timeout + cmd = SafeExec.run(action_string) + else + cmd = SafeExec.run(action_string, timeout.to_i) + end + + if cmd.fail? + puts cmd.stdout + puts cmd.stderr + end + + if !expected_result.nil? + expect(cmd.success?).to be(expected_result), + "This command didn't #{expected_result ? 'succeed' : 'fail'} as expected:\n"<< + " #{action_string}\n" << + "#{cmd.stdout.nil? ? '' : ' ' + cmd.stdout}"<< + "#{cmd.stderr.nil? ? '' : ' ' + cmd.stderr}" + end + + cmd + end + + def cli_create(action_string, template = nil, expected_result = true) + if !template.nil? + file = Tempfile.new('functionality') + file << template + file.flush + file.close + method(REMOTE_COPY).call file.path if REMOTE_FRONTEND + + action_string += " #{file.path}" + end + + cmd = cli_action(action_string, expected_result) + + if expected_result == false + return cmd + end + + regexp_resource_id = /^(\w+ )*ID: (\d+)/ + expect(cmd.stdout).to match(regexp_resource_id) + m = cmd.stdout.match(regexp_resource_id) + m[-1].to_i + end + + # + # Pass the template via STDIN to the CLI creation command. The command must support STDIN. + # + # @param [String] cmd ID returning command + # @param [String] template Template to be passed via STDIN + # @param [Bool] expected_result Whether the command should succeed or fail + # + # @return [Int/False] ID of the created OpenNebula object. False if expected to fail + # + def cli_create_stdin(cmd, template, expected_result = true) + template = template.gsub('$', '\\$') if template.include?('$') + cmd = <<~BASH + #{cmd} </) + root_element = m[1] + + elem = XMLElement.new + elem.initialize_xml(cmd.stdout, root_element) + elem + end + + def cli_action_json(action_string, expected_result = true) + cmd = cli_action(action_string, expected_result) + JSON.parse(cmd.stdout) + end + + def wait_loop(options = {}, &block) + args = { + :timeout => DEFAULT_TIMEOUT, + :success => true + }.merge!(options) + + timeout = args[:timeout] + success = args[:success] + break_cond = args[:break] + + timeout_reached = nil + v = nil + t_start = Time.now + + while Time.now - t_start < timeout + v = block.call + + if break_cond + if break_cond.instance_of? Regexp + if args[:resource_ref].nil? + expect(v).to_not match(break_cond) + else + expect(v).to_not match(break_cond), + "expected #{v} not to match /#{break_cond.source}/ #{args[:resource_type].nil? ? '' : args[:resource_type]}(#{args[:resource_ref]})" + end + else + if args[:resource_ref].nil? + expect(v).to_not eq(break_cond) + else + expect(v).to_not eq(break_cond), + "expected: value != #{v}\ngot: #{break_cond}\n\n(compared using ==)\n#{args[:resource_type].nil? ? '' : args[:resource_type]}(#{args[:resource_ref]})" + end + end + end + + if success.instance_of? Regexp + result = success.match(v) + else + result = v == success + end + + if result + timeout_reached = false + return v + else + sleep 1 + end + end + + pp "Waited #{Time.now - t_start}" + + if timeout_reached != false + FileUtils.mkdir_p "#{ONE_VAR_LOCATION}/wait_loop/" + timestamp = Time.now.strftime('%Y%m%d-%H%M') + hosts_xml = cli_action('onehost list -x', expected_result = nil) + vms_xml = cli_action('onevm list -x', expected_result = nil) + File.open("#{ONE_VAR_LOCATION}/wait_loop/wait_hook_onehost-#{timestamp}-stdout.debug", + 'w') do |f| + f.write("#{hosts_xml.stdout}\n") + end + File.open("#{ONE_VAR_LOCATION}/wait_loop/wait_hook_onehost-#{timestamp}-stderr.debug", + 'w') do |f| + f.write("#{hosts_xml.stderr}\n") + end + File.open("#{ONE_VAR_LOCATION}/wait_loop/wait_hook_onehost-#{timestamp}-status.debug", + 'w') do |f| + f.write("#{hosts_xml.status}\n") + end + File.open("#{ONE_VAR_LOCATION}/wait_loop/wait_hook_onevm-#{timestamp}-stdout.debug", + 'w') do |f| + f.write("#{vms_xml.stdout}\n") + end + File.open("#{ONE_VAR_LOCATION}/wait_loop/wait_hook_onevm-#{timestamp}-stderr.debug", + 'w') do |f| + f.write("#{vms_xml.stderr}\n") + end + File.open("#{ONE_VAR_LOCATION}/wait_loop/wait_hook_onevm-#{timestamp}-status.debug", + 'w') do |f| + f.write("#{vms_xml.status}\n") + end + end + + expect(timeout_reached).to be(false), + "reached timeout, last state was #{v} #{args[:resource_type].nil? ? '' : args[:resource_type]}#{args[:resource_ref].nil? ? '' : '(' + args[:resource_ref].to_s + ')'} while expected #{success}" + expect(v).to be_truthy + end + + ################################################################################ + # User related helpers + ################################################################################ + + ## + # Execute the commands inside the block as the specified user. The user had to be + # created using the cli_create_user helper + # + # @example + # as_user(new_user) { + # cli_action("onedatastore delete 12345") + # } + # + # @param [String] username + def as_user(username, &block) + previous_auth = ENV['ONE_AUTH'] || ENV['HOME'] + '/.one/one_auth' + ENV['ONE_AUTH'] = ENV["#{username.upcase}_AUTH"] + begin + block.call + ensure + ENV['ONE_AUTH'] = previous_auth + end + end + + ## + # Execute the commands inside the block as the specified user using a token. + # + # @example + # as_user_token(user, token) { + # cli_action("onedatastore delete 12345") + # } + # + # @param [String] username + # @param [String] token + def as_user_token(username, token, &block) + previous_auth = ENV['ONE_AUTH'] || ENV['HOME'] + '/.one/one_auth' + + auth_file = File.open("/tmp/auth_#{username}_#{token}", 'w', 0o644) + auth_file.write("#{username}:#{token}") + auth_file.close + + ENV['ONE_AUTH'] = "/tmp/auth_#{username}_#{token}" + + begin + block.call + ensure + ENV['ONE_AUTH'] = previous_auth + end + end + + # Create a new user and define an environment variable pointing to his auth_file + # + # @example + # cli_create_user(username, userpassword) + # + # @param [String] username + # @param [String] password + def cli_create_user(username, password) + id = cli_create("oneuser create #{username} #{password}") + + auth_file = File.open("/tmp/auth_#{username}", 'w', 0o644) + auth_file.write("#{username}:#{password}") + auth_file.close + + ENV["#{username.upcase}_AUTH"] = "/tmp/auth_#{username}" + + id + end + + def wait_app_ready(timeout, app) + ready_state = '1' + error_state = '3' + wait_loop(:success => ready_state, :break => error_state, :timeout => timeout, + :resource_ref => app) do + xml = cli_action_xml("onemarketapp show -x #{app}", nil) rescue nil + xml['STATE'] unless xml.nil? + end + end + + def wait_service_ready(timeout, service) + ready_state = '2' # RUNNING + error_state = '7' # FAILED_DEPLOYING + wait_loop(:success => ready_state, :break => error_state, :timeout => timeout, + :resource_ref => service) do + json = cli_action_json("oneflow show -j '#{service}'", nil) rescue nil + json.dig('DOCUMENT', 'TEMPLATE', 'BODY', 'state').to_s unless json.nil? + end + end + + def wait_image_ready(timeout, image) + wait_loop(:success => 'READY', :break => 'ERROR', :timeout => timeout, + :resource_ref => image) do + xml = cli_action_xml("oneimage show -x #{image}") + Image::IMAGE_STATES[xml['STATE'].to_i] + end + end + + def get_hook_exec(hook_name, next_exec = true) + xpath = '/HOOK/HOOKLOG/HOOK_EXECUTION_RECORD/EXECUTION_ID' + hook_xml = cli_action_xml("onehook show #{hook_name} -x") + last_exec = hook_xml.retrieve_elements(xpath) + ret = -1 + + ret = last_exec[-1].to_i unless last_exec.nil? + + ret += 1 if next_exec + + ret + end + + def wait_hook(hook_name, last_exec) + wait_loop do + hook_xml = cli_action_xml("onehook show #{hook_name} -x") + c_exec = get_hook_exec(hook_name, false) + xpath_rc = "/HOOK/HOOKLOG/HOOK_EXECUTION_RECORD[EXECUTION_ID=#{c_exec}]//CODE" + + if c_exec == last_exec + break true if hook_xml && hook_xml[xpath_rc] == '0' + + break false + + end + end + end + + def debug_action(action_string, info, file = '/tmp/tester_debug') + action_string = pre_cli_action(action_string) + + cmd = SafeExec.run(action_string) + + File.open(file, + 'a') do |f| + f << "============================================================================================================\n" + f << action_string + ' - ' + info + f << "\n\n" + f << cmd.stdout + f << "============================================================================================================\n" + end + end + + def run_fsck(errors = 0, restart = true, dry = false) + return unless DO_FSCK + + @one_test.stop_one + + wait_loop do + !File.exist?('/var/lock/one/one') + end + + fsck = '' + if @one_test.is_sqlite? + fsck = "onedb fsck -v -f --sqlite #{ONE_DB_LOCATION}/one.db" + else + backend= @main_defaults[:db]['BACKEND'] + user = @main_defaults[:db]['USER'] + pass = @main_defaults[:db]['PASSWD'] + dbname = @main_defaults[:db]['DB_NAME'] + + fsck = "onedb fsck -v -f -t #{backend} -u #{user} -p #{pass} -d #{dbname}" + end + + fsck += ' --dry' if dry + + cmd = cli_action(fsck) + + @one_test.start_one if restart + + expect(cmd.stdout).to match(/Total errors found: #{errors}/) + end + + # end module CLITester + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/DiskResize.rb b/apps-code/community-apps/appliances/lib/community/clitester/DiskResize.rb new file mode 100644 index 0000000..cb18a12 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/DiskResize.rb @@ -0,0 +1,59 @@ +module DiskResize + + # TODO: Return output in MB. Add an options = {:unit} to default to MB =>>> / (1024 * 1024) + def get_disk_size(vm, id = 0) + target = vm.xml["TEMPLATE/DISK[DISK_ID=\"#{id}\"]/TARGET"] + vm_mad = vm.xml['HISTORY_RECORDS/HISTORY[last()]/VM_MAD'] + + # Linux + if vm.ssh("test -d /sys/block/#{target}/").success? + cmd = "echo \\$(cat /sys/block/#{target}/queue/physical_block_size) " << + "\\$(cat /sys/block/#{target}/size)" + + cmd = vm.ssh(cmd) + bs, sectors = cmd.stdout.strip.split(' ') + return bs.to_i * sectors.to_i + end + + # FreeBSD, translate target devices + # TODO: not sure about sd/hd + case target + when /^vd([a-z])$/ + target = 'vtbd' + (::Regexp.last_match(1)[0].ord - 'a'.ord).to_s + + when /^sd([a-z])$/ + target = 'da' + (::Regexp.last_match(1)[0].ord - 'a'.ord).to_s + + when /^hd([a-z])$/ + target = 'ad' + (::Regexp.last_match(1)[0].ord - 'a'.ord).to_s + end + + cmd = vm.ssh("diskinfo /dev/#{target}") + if cmd.success? + return cmd.stdout.strip.split("\t")[2].to_i + end + + -1 + end + + # TODO: Return output in MB. Add an options = {:unit} to default to MB =>>> / (1024 * 1024) + def get_fs_size(vm, fs = '/') + cmd = vm.ssh("df -P #{fs} | sed 1d | awk '{ print \\$2 }'") + cmd.stdout.strip.to_i + end + + def again(expected, times = 10) + value = nil + + while times > 0 + value = yield + break if value == expected + + times -= 1 + sleep 1 + end + + value + end + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/OneFlowService.rb b/apps-code/community-apps/appliances/lib/community/clitester/OneFlowService.rb new file mode 100644 index 0000000..92278a1 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/OneFlowService.rb @@ -0,0 +1,40 @@ +module CLITester +require 'json' + +# methods +class OneFlowService + include RSpec::Matchers + + attr_accessor :id + + def initialize(id) + @id = id + + json + end + + def info + cmd = SafeExec.run("oneflow show --json #{@id}") + @str = cmd.stdout + end + + def json(refresh=true) + info if refresh + @json = JSON.parse(@str) + end + + def get_role(role_name) + @json["DOCUMENT"]["TEMPLATE"]["BODY"]["roles"].each do |role| + return role if role["name"] == role_name + end + end + + def get_role_vm_ids(role) + return get_role(role)['nodes'].map do |node| + node["vm_info"]["VM"]["ID"] + end + end + +end +# end module CLITester +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/SafeExec.rb b/apps-code/community-apps/appliances/lib/community/clitester/SafeExec.rb new file mode 100644 index 0000000..d01eee1 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/SafeExec.rb @@ -0,0 +1,102 @@ +require 'open3' + +module CLITester +# methods + +class SafeExec + include RSpec::Matchers + + attr_reader :status, :stdout, :stderr + + def self.run(cmd, timeout = DEFAULT_EXEC_TIMEOUT, try=1, quiet=false) + e = self.new(cmd, timeout, quiet) + while ( !e.success? && try > 0 ) do + e.run! + try -= 1 + !e.success? && try > 0 && sleep(1) + end + e + end + + def initialize(cmd, timeout = DEFAULT_EXEC_TIMEOUT, quiet = false) + @cmd = cmd + @timeout = timeout + @defaults = RSpec.configuration.defaults + @debug = @defaults[:debug] + @quiet = quiet + + @status = nil + @stdout = nil + @stderr = nil + end + + def run! + puts "RUN (#{Time.now}): #{@cmd}" if @debug + + begin + stdout = "" + stderr = "" + status = 0 + out = nil + err = nil + + Timeout::timeout(@timeout) { + stdin, stdout, stderr, wait_thr = Open3.popen3(@cmd) + + out = Thread.new do + ret = stdout.read + stdout.close unless stdout.closed? + ret + end + + err = Thread.new do + ret = stderr.read + stderr.close unless stderr.closed? + ret + end + + status = wait_thr.value + + stdin.close unless stdin.closed? + } + + @status = status.exitstatus + @stdout = out.value if out + @stderr = err.value if err + rescue Timeout::Error + timeout_msg = "Timeout Reached for: '#{@cmd}'" + STDERR.puts timeout_msg unless @quiet + @status = -1 + @stderr = timeout_msg + end + + if fail? && !@stderr.empty? + STDERR.puts @stderr unless @quiet + end + + pp @status if @debug + end + + def success? + @status == 0 + end + + def fail? + !success? + end + + def exitstatus + @status + end + + def expect_success + expect(success?).to be(true), "Expected success for: #{@cmd}\n#{@stderr}" + end + + def expect_fail + expect(fail?).to be(true), "Expected fail for: #{@cmd}\n#{@stderr}" + end +end + +# end module CLITester +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/TempTemplate.rb b/apps-code/community-apps/appliances/lib/community/clitester/TempTemplate.rb new file mode 100644 index 0000000..002907e --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/TempTemplate.rb @@ -0,0 +1,18 @@ +class TempTemplate + attr_accessor :f, :body + + def initialize(body) + @f = Tempfile.new('temptemplate') + @f.write(body) + @f.close + @body = body + end + + def path + @f.path + end + + def unlink + @f.unlink + end +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/TemplateParser.rb b/apps-code/community-apps/appliances/lib/community/clitester/TemplateParser.rb new file mode 100644 index 0000000..4fd3168 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/TemplateParser.rb @@ -0,0 +1,141 @@ +############################################################################### +# The TemplateParser Class parses a VM template file and builds a hash with +# the info. It does not check syntax. +############################################################################### +class TemplateParser + ########################################################################## + # Patterns to parse the template File + ########################################################################## + + NAME_REG =/[\w\d_-]+/ + VARIABLE_REG =/\s*(#{NAME_REG})\s*=\s*/ + + SIMPLE_VARIABLE_REG =/#{VARIABLE_REG}([^\[]+?)(#.*)?/ + SINGLE_VARIABLE_REG =/^#{SIMPLE_VARIABLE_REG}$/ + ARRAY_VARIABLE_REG =/^#{VARIABLE_REG}\[(.*?)\]/m + + ########################################################################## + ########################################################################## + + def initialize(template_string) + @conf=parse_conf(template_string) + end + + def add_configuration_value(key,value) + add_value(@conf,key,value) + end + + def [](key) + @conf[key.to_s.upcase] + end + + def hash + @conf + end + + def self.template_like_str(attributes, indent=true) + if indent + ind_enter="\n" + ind_tab=' ' + else + ind_enter='' + ind_tab=' ' + end + + str=attributes.collect do |key, value| + if value + str_line="" + if value.class==Array + + value.each do |value2| + str_line << key.to_s.upcase << "=[" << ind_enter + if value2 && value2.class==Hash + str_line << value2.collect do |key3, value3| + str = ind_tab + key3.to_s.upcase + "=" + str += "\"#{value3.to_s}\"" if value3 + str + end.compact.join(",\n") + end + str_line << "\n]\n" + end + + elsif value.class==Hash + str_line << key.to_s.upcase << "=[" << ind_enter + + str_line << value.collect do |key3, value3| + str = ind_tab + key3.to_s.upcase + "=" + str += "\"#{value3.to_s}\"" if value3 + str + end.compact.join(",\n") + + str_line << "\n]\n" + + else + str_line< success_state, + :break => break_cond, + :resource_ref => @id, + :resource_type => self.class + }.merge!(options) + wait_loop(args) { state } + end + + def running?(options = {}) + state?('RUNNING', /FAIL/, options) + end + + def pending?(options = {}) + state?('PENDING', /FAIL/, options) + end + + def backing_up?(status_previous) + if status_previous == 'POWEROFF' + state?('BACKUP_POWEROFF') + else + state?('BACKUP') + end + end + + def flatten_inactive?(timeout = 60) + wait_loop(:success => false, + :break => nil, + :timeout => timeout, + :resource_ref => self.class) do + info + backup_config['ACTIVE_FLATTEN'] == 'YES' + end + end + + def stopped? + state?('POWEROFF') + end + + alias poweroff? stopped? + + def failed? + state?('FAILURE') + end + + def done?(options = {}) + state?('DONE', /FAIL/, options) + end + + def undeployed? + state?('UNDEPLOYED') + end + + def reachable?(user = 'root', timeout = DEFAULT_TIMEOUT, ssh_timeout = 11) + options = {} + options[:timeout] = timeout + options[:resource_ref] = @id + options[:resource_type] = self.class + + wait_loop(options) do + cmd = ssh('echo', true, { :timeout => ssh_timeout, :quiet => true }, user) + get_ip + cmd.success? + end + end + + def ready?(success = 'YES') + wait_loop(:timeout => 30) do + expect(xml['USER_TEMPLATE/READY']).to eq(success) + end + end + + def wait_ping(ip = @ip) + err = 'wait_ping: Missing IP to ping' + expect(ip).not_to be_nil, err + expect(ip).not_to be_empty, err + + wait_loop do + system("ping -q -W1 -c1 #{ip} >/dev/null") + end + end + + def wait_no_ping(ip = @ip) + err = 'wait_no_ping: Missing IP to ping' + expect(ip).not_to be_nil, err + expect(ip).not_to be_empty, err + + wait_loop do + !system("ping -q -W1 -c1 #{ip} >/dev/null") + end + end + + def wait_context + wait_loop do + ssh('test -f /var/run/one-context/context.sh.network').success? + end + + wait_loop do + ssh('test -e /var/run/one-context/one-context.lock').fail? + end + end + + def info + @xml = cli_action_xml("onevm show -x #{@id}") + end + + def xml(refresh = true) + info if refresh + @xml + end + + def get_ip + @ip = @xml["#{@defaults[:xpath_pub_nic]}/IP"] + end + + def get_vlan_ip + @ip = @xml["#{@defaults[:xpath_vlan_nic]}/IP"] + end + + def sequences + xml['HISTORY_RECORDS/HISTORY[last()]/SEQ'].to_i + end + + # TODO: Call xml instead of @xml for these helpers to get an up to date VM Template. + # This might cause unwanted effects in already existing tests but is worth noting that calling @xml will + # not necessarily yield an up to date VM XML entry. An explicit info call is necessary. + + def hostname + @xml['HISTORY_RECORDS/HISTORY[last()]/HOSTNAME'] + end + + alias host hostname + + def host_id + @xml['HISTORY_RECORDS/HISTORY[last()]/HID'] + end + + def cluster_id + @xml['HISTORY_RECORDS/HISTORY[last()]/CID'] + end + + def vnet_id(nic_id) + @xml["TEMPLATE/NIC[NIC_ID=\"#{nic_id}\"]/NETWORK_ID"] + end + + def stime + @xml['HISTORY_RECORDS/HISTORY[last()]/STIME'] + end + + def backup_config + @xml.retrieve_xmlelements('BACKUPS/BACKUP_CONFIG')[0] + end + + def backup_ids + info + + ids = @xml.retrieve_elements('BACKUPS/BACKUP_IDS/ID') + + return [] if ids.nil? + + ids + end + + def backup_id(index = -1) + backup_ids[index] + end + + def disks + @xml.retrieve_xmlelements('TEMPLATE/DISK') + end + + def nic_ids + ids = [] + + @xml.retrieve_xmlelements('TEMPLATE/NIC/NIC_ID').each do |nic_xml| + ids << nic_xml['//NIC_ID'] + end + + ids + end + + def networking? + !nic_ids.nil? + end + + def file_write(path = '/var/tmp/asdf', content = 'asdf') + cmds = [] + cmds << "echo '#{content}' > #{path}" + cmds << 'sync' # actually necesary or file being written is a coinflip + cmds << "cat #{path} " + + cmds.each do |c| + ssh(c) + end + + host = Host.new host_id + host.ssh('sync') + + `sync` # sync the front-end (e.g. shared FS, and Backup repo) + end + + def file_check_contents(path, contents) + cmd = ssh("cat #{path}") + + cmd.expect_success + + expect(cmd.stdout.strip).to eq contents.strip + end + + def file_check(path = '/var/tmp/asdf', expect_success = true) + cmd = ssh("cat #{path}") + + if expect_success + cmd.expect_success + else + cmd.expect_fail + end + end + + def debug_ssh_cmd(dcmd) + cmd = ssh(dcmd) + + pp "stdout: #{cmd.stdout}" + pp "status: #{cmd.exitstatus}" + pp "stderr: #{cmd.stdout}" + + expect(cmd.success?).to be(true) + end + + def routes + case os_type + # default via 172.20.0.1 dev eth0 proto static + # 169.254.16.9 dev eth0 scope link + # 172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 linkdown + # 172.20.0.0/16 dev eth0 proto kernel scope link src 172.20.7.16 + # 192.168.110.0/24 dev br1 proto kernel scope link src 192.168.110.1 metric 425 linkdown + # 192.168.150.0/24 dev br0 proto kernel scope link src 192.168.150.1 metric 426 + when 'Linux' + ssh('ip r').stdout.chomp + # TODO: Update parsing for BSD to match Linux + # Destination Gateway Flags Netif Expire + # default 192.168.150.1 UGS vtnet0 + # 127.0.0.1 link#2 UH lo0 + # 192.168.150.0/24 link#1 U vtnet0 + # 192.168.150.100 link#1 UHS lo0 + when 'FreeBSD' + ssh('netstat -rn -f inet | tail -n +4').stdout.chomp + else + STDERR.puts "OS type: #{os_type} not known" + nil + end + end + + def os_type + ssh('uname').stdout.chomp + end + + # rubocop:disable Metrics/ParameterLists + def ssh(cmd, _stderr = false, options = {}, user = 'root', xpath_ip = '') + # stderr ? stderr = '' : stderr = '2>/dev/null' + xpath_ip.empty? ? ip = @ip : ip = @xml[xpath_ip] + # params = ["ssh #{VM_SSH_OPTS} #{user}@#{ip} \"#{cmd}\" #{stderr}"] + params = ["ssh #{VM_SSH_OPTS} #{user}@#{ip} \"#{cmd}\""] + params << options[:timeout] + params << 1 # one try + params << options[:quiet] + + SafeExec.run(*params) + end + + # Method enforces the SSH control master (possibly configured via + # VM_SSH_OPTS) for particular host to stop and close connection. + def ssh_stop_control_master(stderr = false, options = {}, user = 'root', xpath_ip = '') + stderr ? stderr = '' : stderr = '2>/dev/null' + xpath_ip.empty? ? ip = @ip : ip = @xml[xpath_ip] + params = ["ssh #{VM_SSH_OPTS} -O stop #{user}@#{ip} #{stderr}"] + params << options[:timeout] + params.compact! + + SafeExec.run(*params) + end + + def scp(src, dst, stderr = false, options = {}, user = 'root', xpath_ip = '') + stderr ? stderr = '' : stderr = '2>/dev/null' + xpath_ip.empty? ? ip = @ip : ip = @xml[xpath_ip] + params = ["scp #{VM_SSH_OPTS} #{src} #{user}@#{ip}:#{dst} #{stderr}"] + params << options[:timeout] + params.compact! + + SafeExec.run(*params) + end + # rubocop:enable Metrics/ParameterLists + + def resume + cli_action("onevm resume #{@id}") + running? + end + + def safe_poweroff + cli_action("onevm poweroff #{@id}") + + # just in case the VM has no ACPI... + ssh("PATH=#{DEFAULT_PATH} poweroff") if @defaults[:emulate_acpi] + + state?('POWEROFF') + end + + alias poweroff safe_poweroff + + def poweroff_hard + cli_action("onevm poweroff --hard #{@id}") + stopped? + end + + def backup(datastore = @backup_ds_id, options = {}) + opts = { :args => '', :wait => true }.merge!(options) + + status = state + + if datastore.nil? + cli_action("onevm backup #{@id} #{opts[:args]}") + else + cli_action("onevm backup #{@id} -d #{datastore} #{opts[:args]}") + end + + return unless opts[:wait] + + backing_up?(status) + state?(status) + + info + backup_id + end + + def backup_cancel(status_previous = nil, options = {}) + opts = { :args => '' }.merge!(options) + + status_previous = state if status_previous.nil? + + backing_up?(status_previous) + + wait_loop(:success => status_previous, + :resource_ref => @id, + :resource_type => self.class) do + status = state + if ['BACKUP', 'BACKUP_POWEROFF'].include?(status) + cli_action("onevm backup-cancel #{@id} #{opts[:args]}", nil, true) + end + status + end + end + + def backup_fail(datastore = @backup_ds_id, options = {}) + opts = { :args => '' }.merge!(options) + cli_action("onevm backup #{@id} -d #{datastore} #{opts[:args]}", false) + end + + def backups? + !backup_ids.empty? + end + + def set_backup_mode(mode, keep_last = nil, imode = nil) + bkp_cfg = [] + bkp_cfg << %(MODE="#{mode}") + bkp_cfg << %(KEEP_LAST="#{keep_last}") unless keep_last.nil? + bkp_cfg << %(INCREMENT_MODE="#{imode}") unless imode.nil? + + updateconf %(BACKUP_CONFIG = [#{bkp_cfg.join(',')}]) + end + + def updateconf(template_str, options = '') + file = Tempfile.new('vm_conf') + file.write(template_str) + file.close + + cmd = "onevm updateconf #{@id} #{file.path} #{options}" + cli_action(cmd) + + file.unlink + + @xml + end + + def update(template, append = false) + cmd = "echo #{template} | onevm update #{@id}" + cmd << ' -a' if append + cli_action(cmd) + end + + def recontextualize(context_param) + context_template = "CONTEXT=[ #{context_param} ]" + + if state == 'RUNNING' + was_running = true + cmd1 = ssh('stat -c "%Y" /var/run/one-context/context.sh.network') + cmd1.success? + end + + updateconf(context_template, '-a') + + return unless was_running + + # wait context.sh.network is newer now -> context started + wait_loop do + cmd2 = ssh('stat -c "%Y" /var/run/one-context/context.sh.network') + cmd2.stdout.to_i > cmd1.stdout.to_i + end + + wait_context + end + + def safe_undeploy + cli_action("onevm undeploy #{@id}") + + # just in case the VM has no ACPI... + ssh("PATH=#{DEFAULT_PATH} poweroff") if @defaults[:emulate_acpi] + + state?('UNDEPLOYED') + end + + def resched_running + cli_action("onevm resched #{@id}") + state?('RUNNING') + end + + def resched + verify_action("onevm resched #{@id}") + end + + alias undeploy safe_undeploy + + def undeploy_hard + cli_action("onevm undeploy --hard #{@id}") + undeployed? + end + + def safe_reboot + cli_action("onevm reboot #{@id}") + + # just in case the VM has no ACPI... + ssh("PATH=#{DEFAULT_PATH} reboot") if @defaults[:emulate_acpi] + + state?('RUNNING') + end + + def hard_reboot + cli_action("onevm reboot --hard #{@id}") + + state?('RUNNING') + end + + alias reboot safe_reboot + + def halt + ssh("PATH=#{DEFAULT_PATH} halt") + sleep 10 + end + + def terminate(options = {}) + cli_action("onevm terminate #{@id}") + state?('DONE', /FAIL/, options) + end + + def terminate_hard + cli_action("onevm terminate --hard #{@id}") + state?('DONE') + end + + def migrate_live(host_next = nil) + running? + migrate(host_next, '--live') + end + + def migrate(host_next = nil, options = '') + if !host_next + all_hosts = cli_action_json('onehost list -j') + all_ids = all_hosts.dig('HOST_POOL', 'HOST').map {|h| h['ID'].to_i } + host_next = (all_ids - [host_id.to_i]).first + end + + pp "migrate to host: #{host_next}" + + verify_action("onevm migrate #{@id} #{host_next} #{options}") + end + + def nic_attach(net_id, options = {}) + status = state + + cmd = "onevm nic-attach #{@id} --network #{net_id} " \ + "#{options.map {|k, v| "--#{k} #{v}" }.join(' ')}" + + cli_action(cmd) + + state?(status) + end + + def nic_detach(nic_id) + status = state + + cmd = "onevm nic-detach #{@id} #{nic_id}" + cli_action(cmd) + + state?(status) + end + + def disk_attach(img_id, options = {}) + status = state + + cmd = "onevm disk-attach #{@id} --image #{img_id} " \ + "#{options.map {|k, v| "--#{k} #{v}" }.join(' ')}" + + cli_action(cmd) + + state?(status) + end + + def disk_detach(disk_id) + status = state + + cmd = "onevm disk-detach #{@id} #{disk_id}" + cli_action(cmd) + + state?(status) + end + + def disk_snapshot_create(id_disk, name_snap, options = '') + cmd = "onevm disk-snapshot-create #{@id} #{id_disk} #{name_snap} #{options}" + verify_action(cmd) + end + + def snapshot_create(options = '') + verify_action("onevm snapshot-create #{@id} #{options}") + end + + def snapshot_delete(snap_id, options = '') + verify_action("onevm snapshot-delete #{@id} #{snap_id} #{options}") + end + + def vmware_tools_running? + wait_loop do + info + @xml['MONITORING/VCENTER_VMWARETOOLS_RUNNING_STATUS'] == 'guestToolsRunning' + end + end + + def wait_monitoring_info(att) + wait_loop do + info + if !(ip = @xml["MONITORING/#{att}"]).nil? + return ip + end + end + end + + # execute remote command on a windows VM using openssh + def winrm(command, user = 'oneadmin') + cmd = ssh(command, false, {}, user, '') + + o = cmd.stdout.chomp + e = cmd.stderr.chomp + s = cmd.success? + + lines = [] + + # remove windows stinky \r\n on each line + o.lines.each do |l| + lines << l.chomp + end + + o = lines.join("\n") + lines = [] + + # remove windows stinky \r\n on each line + e.lines.each do |l| + lines << l.chomp + end + + e = lines.join("\n") + + if s == false + pp command + pp o + pp e + end + + [o, e, s] + end + + def powershell(command, user = 'oneadmin') + winrm("powershell -Command \\\"#{command}\\\"", user) + end + + def clear_ready + Tempfile.open('tmpl') do |tmpl| + tmpl << 'READY=""' + tmpl.close + + cli_action("onevm update --append #{@id} #{tmpl.path}") + end + end + + def instance_name + "one-#{@id}" + end + + alias name instance_name + + private + + # + # Checks if the initial VM state is restored after performing certain VM action + # + # @param [Proc] action VM action code: cli_action("onevm terminate --hard #{@id}") + # + # @return [Bool] True if status after performing action is the same as the inital status + # + def verify_state(action) + raise "Expected #{Proc} type object, not #{action.class}" if action.class != Proc + + info + + state = VirtualMachine::VM_STATE[@xml['STATE'].to_i] + lcm_state = VirtualMachine::LCM_STATE[@xml['LCM_STATE'].to_i] + + action.call + + wait_loop( + :break => /FAIL/, + :resource_ref => @id, + :resource_type => self.class + ) do + info + + cstate = VirtualMachine::VM_STATE[@xml['STATE'].to_i] + clcm_state = VirtualMachine::LCM_STATE[@xml['LCM_STATE'].to_i] + + cstate == state && clcm_state == lcm_state + end + end + + # + # Wrapper for verify state, intented for an individual action + # + # @param [String] cmd opennebula command to be run + # + # @return [Bool] True if the previous state is returned to + # + def verify_action(cmd) + action = proc { cli_action(cmd) } + verify_state(action) + end + + end + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/VMTemplate.rb b/apps-code/community-apps/appliances/lib/community/clitester/VMTemplate.rb new file mode 100644 index 0000000..c715cce --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/VMTemplate.rb @@ -0,0 +1,36 @@ +require 'oneobject' +require 'VM' + +module CLITester + + class VMTemplate < OneObject + + def self.onecmd + 'onetemplate' + end + + # + # Instantiates a VM template and validates VM RUNNING status + # + # @param [Bool] ssh Validate VM SSH access from the FE + # @param [String] options custom CLI arguments + # + # @return [CLITester::VM] + # + def instantiate(ssh = false, options = '') + vm = VM.instantiate(@id, ssh, options) + vm.running? + vm.reachable? if ssh + + vm + end + + def delete(recursive = false, options = '') + args = options + args << ' --recursive' if recursive + super(args) + end + + end + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/VN.rb b/apps-code/community-apps/appliances/lib/community/clitester/VN.rb new file mode 100644 index 0000000..fd3b2e6 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/VN.rb @@ -0,0 +1,66 @@ +require 'oneobject' + +module CLITester + + class VN < OneObject + + def self.onecmd + 'onevnet' + end + + # Actions + + # Creates a new datastore in OpenNebula using a template defintion + # + # @param [String] template OpenNebula template definition + # + # @return [Datastore] Datastore CLITester object + # + def self.create(template) + file = Tempfile.new('ds_template') + file.write(template) + file.close + + cmd = "#{onecmd} create #{file.path}" + vnet = new(cli_create_lite(cmd)) + + file.unlink + + vnet + end + + def delete + cli_action("#{self.class.onecmd} delete #{@id}") + end + + # Info + + def ready? + state?('READY') + end + + def error? + state?('ERROR', :break_cond => 'READY') + end + + # VN_STATES=%w{ + # INIT READY LOCK_CREATE LOCK_DELETE DONE ERROR UPDATE_FAILURE + # } + def state + info + OpenNebula::VirtualNetwork::VN_STATES[@xml['STATE'].to_i] + end + + def state?(state_target, break_cond = 'ERROR') + args = { + :success => state_target, + :break => break_cond, + :resource_ref => @id, + :resource_type => self.class + } + + wait_loop(args) { state } + end + end + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/VNetOVS.rb b/apps-code/community-apps/appliances/lib/community/clitester/VNetOVS.rb new file mode 100644 index 0000000..edc3a2e --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/VNetOVS.rb @@ -0,0 +1,90 @@ +require 'json' + +class OVSNetwork + + include RSpec::Matchers + + def initialize(vnet_id, host_id, oneadmin, timeout = 30) + @defaults = RSpec.configuration.defaults + + @vnid = vnet_id + @host = Host.new(host_id) + + @timeout = timeout + @user = oneadmin + end + + def ssh(cmd) + @host.ssh(cmd, true, { :timeout => @timeout }, @user) + end + + def link_info(link) + rc = ssh("ip --json link show #{link}") + + expect(rc.success?).to be(true) + + JSON.parse(rc.stdout)[0] + end + + def qos_info(vnic) + domain = vnic.split('-')[0..1].join('-') + + rc = ssh("virsh -c qemu:///system domiftune #{domain} #{vnic}") + + expect(rc.success?).to be(true) + + info = {} + rc.stdout.each_line do |l| + next if l.strip.empty? + + fields = l.split(':') + info[fields[0].strip] = fields[1].strip + end + + info + end + + def port_info(port) + rc = ssh("sudo ovs-vsctl list port #{port}") + + expect(rc.success?).to be(true) + + info = {} + + rc.stdout.each_line do |l| + fields = l.split(':') + info[fields[0].strip] = fields[1].strip + end + + info + end + + def list_ports + vnet_info + + rc = ssh("sudo ovs-vsctl list-ports #{@xml['BRIDGE']}") + + expect(rc.success?).to be(true) + + rc.stdout.split + end + + def updated? + vnet_info + @xml['OUTDATED_VMS'].empty? && @xml['UPDATING_VMS'].empty? && @xml['ERROR_VMS'].empty? + end + + def update(template) + cli_update("onevnet update #{@vnid}", template, true) + end + + def vnet_info + @xml = cli_action_xml("onevnet show -x #{@vnid}") + end + + def state + vnet_info + VirtualNetwork::VN_STATES[@xml['STATE'].to_i] + end + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/VNetVLAN.rb b/apps-code/community-apps/appliances/lib/community/clitester/VNetVLAN.rb new file mode 100644 index 0000000..0144145 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/VNetVLAN.rb @@ -0,0 +1,80 @@ +require 'json' + +class VLANNetwork + + include RSpec::Matchers + + def initialize(vnet_id, host_id, oneadmin, timeout = 30) + @defaults = RSpec.configuration.defaults + + @vnid = vnet_id + @host = Host.new(host_id) + + @timeout = timeout + @user = oneadmin + end + + def ssh(cmd) + @host.ssh(cmd, true, { :timeout => @timeout }, @user) + end + + def link_info(link) + rc = ssh("ip --json link show #{link}") + + expect(rc.success?).to be(true) + + JSON.parse(rc.stdout)[0] + end + + def qos_info(vnic) + domain = vnic.split('-')[0..1].join('-') + + rc = ssh("virsh -c qemu:///system domiftune #{domain} #{vnic}") + + expect(rc.success?).to be(true) + + info = {} + rc.stdout.each_line do |l| + next if l.strip.empty? + + fields = l.split(':') + info[fields[0].strip] = fields[1].strip + end + + info + end + + def updated? + vnet_info + @xml['OUTDATED_VMS'].empty? && @xml['UPDATING_VMS'].empty? && @xml['ERROR_VMS'].empty? + end + + def update(template) + cli_update("onevnet update #{@vnid}", template, true) + end + + def vnet_info + @xml = cli_action_xml("onevnet show -x #{@vnid}") + end + + def state + vnet_info + VirtualNetwork::VN_STATES[@xml['STATE'].to_i] + end + + def list_ports + vnet_info + + rc = ssh("ip --json link show master #{@xml['BRIDGE']}") + + expect(rc.success?).to be(true) + + info = JSON.parse(rc.stdout) + ports = [] + + info.each {|i| ports << i['ifname'] } + + ports + end + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/datastore.rb b/apps-code/community-apps/appliances/lib/community/clitester/datastore.rb new file mode 100644 index 0000000..bb9658d --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/datastore.rb @@ -0,0 +1,112 @@ +require 'tempfile' +require 'oneobject' + +module CLITester + + # + # OpenNebula datastore object used to abstract CLI operations on the datastore. + # For specialized datastores that require OS level operations, a child class should be created + # to handle these operations and the special parameters of the datastore definition + # + class Datastore < OneObject + + # + # Creates a new datastore in OpenNebula using a template defintion + # + # @param [String] template OpenNebula template definition + # + # @return [Datastore] Datastore CLITester object + # + def self.create(template) + file = Tempfile.new('ds_template') + file.write(template) + file.close + + cmd = "#{onecmd} create #{file.path}" + datastore = new(cli_create_lite(cmd)) + + file.unlink + + datastore + end + + # + # OpenNebula minimal datastore template format. Extend in more advanced datastores + # + # @param [String] name Datastore name + # @param [String] type Purpose of the datastore: backup/System/Image/File + # @param [String] ds_mad Datastore driver + # @param [String] tm_mad Transfer Manager driver + # + # @return [String] Datastore template definition + # + def self.generate_template(name, type, ds_mad, tm_mad) + <<~EOT + NAME="#{name}" + DS_MAD=#{ds_mad} + TM_MAD=#{tm_mad} + TYPE=#{type} + EOT + end + + def self.onecmd + 'onedatastore' + end + + def delete + super + fs_remove + end + + def fs_remove + FileUtils.remove_dir(path) + rescue StandardError + end + + def drivers + [@xml['DS_MAD'], @xml['TM_MAD']] + end + + # + # Filesystem location of the datastore + # + # @return [String] Path + # + def path + @xml['BASE_PATH'] + end + + def image? + type == 'IMAGE' + end + + def system? + type == 'SYSTEM' + end + + def file? + type == 'FILE' + end + + def backup? + type == 'BACKUP' + end + + def usage_m + { + :total => @xml['TOTAL_MB'], + :free => @xml['FREE_MB'], + :used => @xml['USED_MB'] + } + end + + alias usage usage_m + + # DATASTORE_TYPES=%w[IMAGE SYSTEM FILE BACKUP] + def type + OpenNebula::Datastore::DATASTORE_TYPES[@xml['TYPE'].to_i] + end + + end + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/host.rb b/apps-code/community-apps/appliances/lib/community/clitester/host.rb new file mode 100644 index 0000000..05fb2b0 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/host.rb @@ -0,0 +1,121 @@ +require 'socket' + +module CLITester + + # methods + class Host + + include RSpec::Matchers + + PIN_POLICIES = %w[NONE PINNED] + + # Min Libvirt Version for Full Backups + MLVFL = '5.5' + + # Min Libvirt Version for Incremental Backups + MLVIC = '7.7' + + attr_accessor :id + + def initialize(id) + @id = id + info + end + + def [](key) + @xml[key] + end + + def self.private_ip + address = '' + + Socket.getifaddrs.each do |addr_info| + next unless addr_info.addr && addr_info.addr.ipv4? && addr_info.name == 'eth1' + + address = addr_info.addr.ip_address + end + + address + end + + def pin_policy(policy) + return false unless PIN_POLICIES.include?(policy) + + cmd = "onehost update #{@id}" + tpl = "PIN_POLICY = #{policy}" + + cli_update(cmd, tpl, true) + end + + def pin + pin_policy(PIN_POLICIES[1]) + end + + def unpin + pin_policy(PIN_POLICIES[0]) + end + + def state + info + OpenNebula::Host::HOST_STATES[@xml['STATE'].to_i] + end + + def state?(success_state, break_cond = /FAIL/) + wait_loop(:success => success_state, :break => break_cond, + :resource_ref => @id, :resource_type => self.class) { state } + end + + def monitored? + state?('MONITORED') + end + + def disabled? + state?('DISABLED') + end + + def info + @xml = cli_action_xml("onehost show -x #{@id}") + end + + def xml(refresh = true) + info if refresh + @xml + end + + def ssh(cmd, stderr = false, options = {}, user = 'root') + stderr ? stderr = '' : stderr = '2>/dev/null' + params = ["ssh #{VM_SSH_OPTS} #{user}@#{@xml['NAME']} \"#{cmd}\" #{stderr}"] + params << options[:timeout] + params.compact! + + SafeExec.run(*params) + end + + def ssh_safe(cmd) + ssh(cmd, false, {}, 'oneadmin') + end + + def scp(src, dst, stderr = false, options = {}, user = 'root') + stderr ? stderr = '' : stderr = '2>/dev/null' + params = ["scp #{src} #{user}@#{@xml['NAME']}:#{dst} #{stderr}"] + params << options[:timeout] + params.compact! + + SafeExec.run(*params) + end + + def libvirt_version + ssh_safe('/usr/sbin/libvirtd -V').stdout.split(' ')[2] + end + + def incremental_backups? + Gem::Version.new(libvirt_version) >= Gem::Version.new(MLVIC) + end + + def full_backups? + Gem::Version.new(libvirt_version) >= Gem::Version.new(MLVFL) + end + + end + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/image.rb b/apps-code/community-apps/appliances/lib/community/clitester/image.rb new file mode 100644 index 0000000..2917ea8 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/image.rb @@ -0,0 +1,134 @@ +require 'oneobject' + +module CLITester + # TODO: Use Image name and specify OpenNebula::Image on the tests that use bare Image + + class CLIImage < OneObject + + def self.onecmd + 'oneimage' + end + + # Actions + + def self.create(name = random_name, datastore = 1, options = '') + cmd = "#{onecmd} create --name '#{name}' -d '#{datastore}' #{options}" + + new(cli_create_lite(cmd)) + end + + # + # Returns the list of images in the pool + # + # @return [Array] Existing image entries + # + def self.list(options = '') + cmd = "#{onecmd} list --no-header #{options}" + SafeExec.run(cmd).stdout.split("\n") + end + + # + # Restores an image. Image must be backup type + # + # @param [String] datastore id or name + # + # @return [Array] id of template and id of image + # + def restore(datastore = 1, options = '') + cmd = cli_action("#{self.class.onecmd} restore #{@id} -d #{datastore} #{options}") + + # "VM Template: 19\nImages: 92\n" + ids = cmd.stdout.split("\n") + + template_id = ids[0].split(':')[1].strip + image_id = ids[1].split(':')[1].split unless ids[1].nil? + + [template_id, image_id].flatten.compact + end + + def delete + cli_action("#{self.class.onecmd} delete #{@id}") + end + + def deleted_no_fail?(timeout) + timeout_reached = false + t_start = Time.now + + while Time.now - t_start < timeout do + cmd = cli_action("oneimage show #{@id}", nil, true) + + return true if cmd.fail? + + sleep 1 + end + + timeout_reached + end + + # Info + + def source + @xml['SOURCE'] + end + + def format + @xml['FORMAT'] + end + + def backup_increments + @xml['BACKUP_INCREMENTS'] + end + + # IMAGE_TYPES=%w{OS CDROM DATABLOCK KERNEL RAMDISK CONTEXT BACKUP} + def type + OpenNebula::Image::IMAGE_TYPES[@xml['TYPE'].to_i] + end + + def backup? + type == 'BACKUP' + end + + def persistent? + @xml['PERSISTENT'] == 1 + end + + def size_m + "#{@xml['SIZE']}M" + end + + alias size size_m + + def ready? + state?('READY') + end + + def used? + state?('USED') + end + + def error? + state?('ERROR', :break_cond => 'READY') + end + + # IMAGE_STATES=%w{ + # INIT READY USED DISABLED LOCKED ERROR CLONE DELETE USED_PERS LOCKED_USED LOCKED_USED_PERS + # } + def state + info + OpenNebula::Image::IMAGE_STATES[@xml['STATE'].to_i] + end + + def state?(state_target, break_cond = 'ERROR') + args = { + :success => state_target, + :break => break_cond, + :resource_ref => @id, + :resource_type => self.class + } + + wait_loop(args) { state } + end + + end + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/init.rb b/apps-code/community-apps/appliances/lib/community/clitester/init.rb new file mode 100644 index 0000000..c9cb559 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/init.rb @@ -0,0 +1,84 @@ +#!/usr/bin/env ruby + +ROOT_DIR ||= File.realpath(File.join(__FILE__, '../..')) +LIB_DIR ||= ROOT_DIR + '/lib' + +DEFAULTS_YAML ||= ENV['DEFAULTS'] || ROOT_DIR + '/defaults.yaml' + +ONE_LOCATION = ENV['ONE_LOCATION'] unless defined?(ONE_LOCATION) + +if !ONE_LOCATION + ONE_LIB_LOCATION ||= '/usr/lib/one' + ONE_LOG_LOCATION ||= '/var/log/one' + GEMS_LOCATION ||= '/usr/share/one/gems' unless defined?(GEMS_LOCATION) +else + ONE_LIB_LOCATION ||= ONE_LOCATION + '/lib' + ONE_LOG_LOCATION ||= ONE_LOCATION + '/var' + GEMS_LOCATION ||= ONE_LOCATION + '/share/gems' unless defined?(GEMS_LOCATION) +end + +if File.directory?(GEMS_LOCATION) + real_gems_path = File.realpath(GEMS_LOCATION) + if !defined?(Gem) || Gem.path != [real_gems_path] + $LOAD_PATH.reject! {|l| l =~ /vendor_ruby/ } + require 'rubygems' + Gem.use_paths(real_gems_path) + end +end + +$LOAD_PATH << LIB_DIR +$LOAD_PATH << ONE_LIB_LOCATION + '/ruby' + +require 'yaml' +require 'rspec' +require 'pp' +require 'rexml/document' +require 'tempfile' +require 'fileutils' + +# Load pry if available (useful for debugging) +begin + require 'pry' +rescue LoadError +end + +require 'opennebula' + +require 'CLITester' +require 'SafeExec' +require 'VM' +require 'TemplateParser' +require 'TempTemplate' +require 'OneFlowService' +require 'host' + +include OpenNebula +include CLITester + +def save_log_files(name) + dir = File.join(Dir.pwd, 'results') + FileUtils.mkdir_p(dir) + + sanitized_name = name.gsub(%r{[\s/'"]}, '_') + tar_file = File.join(dir, "#{sanitized_name}.tar.bz2") + + cmd = "tar --ignore-failed-read -cjf '#{tar_file}' #{ONE_LOG_LOCATION} 2>/dev/null" + system(cmd) +end + +RSpec.configure do |c| + c.add_setting :defaults + c.add_setting :main_defaults + begin + # For vcenter-sunstone tests c.defaults is the same as c.main_defaults + c.defaults = YAML.load_file(DEFAULTS_YAML) + c.main_defaults = YAML.load_file(DEFAULTS_YAML) + rescue StandardError + STDERR.puts "Can't load defaults.yaml file. Make sure it exists." + exit(-1) + end + c.before do |_e| + @defaults = c.defaults + @main_defaults = c.main_defaults + end +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/one-open-uri.rb b/apps-code/community-apps/appliances/lib/community/clitester/one-open-uri.rb new file mode 100644 index 0000000..67b5630 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/one-open-uri.rb @@ -0,0 +1,13 @@ +require 'open-uri' + +# ONE_URI provides compatibility interface for OpenURI module by +# using legacy Kernel.open or URI.open calls based on available version +module ONE_URI + + def self.open(*args) + URI.open(*args) + rescue NoMethodError + Kernel.open(*args) + end + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/oneobject.rb b/apps-code/community-apps/appliances/lib/community/clitester/oneobject.rb new file mode 100644 index 0000000..9124ffe --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/oneobject.rb @@ -0,0 +1,127 @@ +require 'securerandom' + +module CLITester + + # Basic OpenNebula object definition with common actions, queries and XML handling capabilities + # Use as a parent class for other OpenNebula objects + class OneObject + + include RSpec::Matchers + + attr_reader :id + + def initialize(id) + @id = id + @defaults = RSpec.configuration.defaults + + info + end + + def [](key) + @xml[key] + end + + def xml(refresh = true) + info if refresh + @xml + end + + def self.onecmd + raise 'implement in child class' + end + + def self.random_name(kinda_name = '') + new_name = "#{name}_#{kinda_name}_#{SecureRandom.uuid}" + new_name.slice!('CLITester::') + new_name + end + + ###### + + # Actions + + # + # Returns the list of objects in the pool + # + # @return [Array] Existing objects + # + def self.list(options = '') + cmd = "#{onecmd} list --no-header -l ID #{options}" + SafeExec.run(cmd).stdout.split("\n") + end + + def chown(user = 'oneadmin', group = 'oneadmin') + cli_action("#{self.class.onecmd} chown #{@id} #{user} #{group}") + info + end + + def rename(name) + cli_action("#{self.class.onecmd} rename #{@id} #{name}") + info + end + + def delete(options = '') + cli_action("#{self.class.onecmd} delete #{@id} #{options}") + end + + # + # Used to check expected failure when deleting the object + # + # @return [SafeExec] onedatastore delete command execution + # + def delete_fail + cli_action("#{self.class.onecmd} delete #{@id}", false) + end + + # Info + + def name + @xml['NAME'] + end + + def ownership + permissions = @xml.retrieve_xmlelements('PERMISSIONS')[0] + + { + :uid => @xml['UID'], + :gid => @xml['GID'], + :user => @xml['UNAME'], + :group => @xml['GNAME'], + :permissions => { + :owner => { + :use => permissions['OWNER_U'], + :manage => permissions['OWNER_M'], + :admin => permissions['OWNER_A'] + }, + :group => { + :use => permissions['GROUP_U'], + :manage => permissions['GROUP_M'], + :admin => permissions['GROUP_A'] + }, + :other => { + :use => permissions['OTHER_U'], + :manage => permissions['OTHER_M'], + :admin => permissions['OTHER_A'] + } + + } + + } + end + + def deleted?(timeout = 60) + wait_loop(:success => true, :break => 'ERROR', :timeout => timeout) do + cmd = cli_action("#{self.class.onecmd} show #{@id}", nil, true) + cmd.fail? + end + end + + private + + def info + @xml = cli_action_xml("#{self.class.onecmd} show -x #{@id}") + end + + end + +end diff --git a/apps-code/community-apps/appliances/lib/community/clitester/opennebula_test.rb b/apps-code/community-apps/appliances/lib/community/clitester/opennebula_test.rb new file mode 100644 index 0000000..f19dba3 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/clitester/opennebula_test.rb @@ -0,0 +1,631 @@ +# This class abstract the configuration start and stop of OpenNebula services +# +# It is based on a defaults.yaml file with the follwing options +# :oned_conf_base: Path to oned.conf base file +# :oned_conf_extra: Path to additional settings for oned.conf for this test +# :sched_conf: Path to the sched.conf file (if not set use default) +# :monitor_conf: Path to the monitord.conf file (if not set use default) +# :nosched: Disable the scheduler for this test if defined +# :ca_cert: CA certificate +class OpenNebulaTest + + READINESS_DIR = File.realpath(File.join(__FILE__,'../..')) + FUNCTIONALITY_DIR = 'spec/functionality' + ONED_CONF_BASE = 'conf/oned.conf.xml' + ONED_DB_CONF = 'conf/db.conf.xml' + ONED_CONF_EXTRA = 'conf/oned.extra.yaml' + SCHED_CONF_BASE = 'conf/sched.conf' + MONITOR_CONF_BASE = 'conf/monitord.conf' + MONITOR_CONF_EXTRA = 'conf/monitord.extra.conf' + SUNSTONE_EXTRA = 'conf/sunstone_extra.yaml' + + # default stop/start commands + CMD_PREFIX = 'env -i - PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin' + NOVNC_START = "#{CMD_PREFIX} novnc-server start" + NOVNC_STOP = "#{CMD_PREFIX} novnc-server stop" + SUNSTONE_START = "#{CMD_PREFIX} sunstone-server start" + SUNSTONE_STOP = "#{CMD_PREFIX} sunstone-server stop" + FIREEDGE_START = "#{CMD_PREFIX} fireedge-server start" + FIREEDGE_STOP = "#{CMD_PREFIX} fireedge-server stop" + + def initialize(options={}) + @options={ + :oned_conf_base => ONED_CONF_BASE, + :oned_conf_extra => ONED_CONF_EXTRA, + :oned_db_conf => ONED_DB_CONF, + :monitor_conf_extra => MONITOR_CONF_EXTRA, + :sunstone_extra => SUNSTONE_EXTRA, + :functionality_dir => FUNCTIONALITY_DIR + }.merge!(options) + + unless @options.has_key? :functionality_abs_dir + @options[:functionality_abs_dir] = READINESS_DIR + '/' + @options[:functionality_dir] + '/' + end + + # load oned.conf.xml + conf = XMLElement.new + conf_file = @options[:functionality_abs_dir] + @options[:oned_conf_base] + conf.initialize_xml(File.read(conf_file), 'OPENNEBULA_CONFIGURATION') + + @one_conf = conf.to_hash + @one_conf = @one_conf['OPENNEBULA_CONFIGURATION'] + + @one_conf.delete('VM_MAD') + @one_conf.delete('IM_MAD') + + # load db.conf.xml + db_conf_file = @options[:functionality_abs_dir] + @options[:oned_db_conf] + if File.exist?(db_conf_file) + begin + db_conf = XMLElement.new + db_conf.initialize_xml(File.read(db_conf_file), 'DB') + db_hash = db_conf.to_hash + rescue + STDERR.puts "Error reading xml #{db_conf_file}, skipping" + else + @one_conf.delete('DB') + @one_conf.merge!(db_hash) + end + end + + # make the DB section available to test (some are not applicable) + RSpec.configuration.main_defaults[:db] = @one_conf['DB'] + + # load oned.extra.yaml + extra_file = @options[:functionality_abs_dir] + @options[:oned_conf_extra] + extra_hash = YAML.load(File.read(extra_file)) + + # load sunstone_extra.yaml + extra_sunstone_file = @options[:functionality_abs_dir] + @options[:sunstone_extra] + @sunstone_conf = YAML.load(File.read("#{ONE_ETC_LOCATION}/sunstone-server.conf")) + sunstone_extra = YAML.load(File.read(extra_sunstone_file)) + @sunstone_conf.merge!(sunstone_extra) + + @sunstone_conf.delete(:tmpdir) + + # merge extra into @one_conf + @one_conf.merge!(extra_hash) unless extra_hash.nil? + end + + #private + ############################################################################ + # Manage oned.conf & sched.conf + ############################################################################ + def set_conf + STDOUT.puts "==> Setting #{ONE_ETC_LOCATION}/oned.conf" + + fd = File.open("#{ONE_ETC_LOCATION}/oned.conf", "w") + fd.write(to_template(@one_conf)) + fd.close + + STDOUT.puts "==> Setting #{ONE_ETC_LOCATION}/sunstone-server.conf" + + fd = File.open("#{ONE_ETC_LOCATION}/sunstone-server.conf", "w") + fd.write(@sunstone_conf.to_yaml) + fd.close + + STDOUT.puts "==> Setting #{ONE_ETC_LOCATION}/sched.conf" + + sched_conf = @options[:sched_conf] || SCHED_CONF_BASE + + FileUtils.cp(@options[:functionality_abs_dir] + sched_conf, + "#{ONE_ETC_LOCATION}/sched.conf") + + monitor_conf = @options[:monitor_conf] || MONITOR_CONF_BASE + + # put monitord.extra.conf at the begining + FileUtils.cp(@options[:functionality_abs_dir] + @options[:monitor_conf_extra], + "#{ONE_ETC_LOCATION}/monitord.conf") + + system("cat #{@options[:functionality_abs_dir] + monitor_conf} " \ + ">> #{ONE_ETC_LOCATION}/monitord.conf") + + if !@options[:ca_cert].nil? + STDOUT.puts "==> Copying CA certificate #{@options[:ca_cert]}" + + FileUtils.mkdir_p("#{ONE_ETC_LOCATION}/auth/certificates") + FileUtils.cp(@options[:functionality_abs_dir] + @options[:ca_cert], + "#{ONE_ETC_LOCATION}/auth/certificates") + end + end + + def start_one + STDOUT.print "==> Starting OpenNebula... " + STDOUT.flush + rc = system('one start') + return false if rc == false + + wait_for_one + + STDOUT.puts "done" + + if RSpec.configuration.main_defaults[:manage_fireedge] + start_fireedge + end + + if RSpec.configuration.main_defaults[:manage_sunstone] + start_sunstone + end + + if @options[:nosched] == true + STDOUT.print "==> Stopping Scheduler... " + STDOUT.flush + stop_sched + + STDOUT.puts "done" + end + + true + end + + def wait_for_one + log_file = "#{ONE_LOG_LOCATION}/oned.log" + text = "Request Manager started" + + wait_loop do + system("egrep '#{text}' #{log_file} > /dev/null") + end + end + + def start_sched + system('one start-sched 2>&1 > /dev/null') + end + + def stop_sched + system('one stop-sched 2>&1 > /dev/null; pkill -9 mm_sched') + end + + def log_backup(name) + orig = ONE_LOG_LOCATION.clone + dst = ONE_VAR_LOCATION == ONE_LOG_LOCATION ? '/tmp' : ONE_VAR_LOCATION + "/#{name}" + + %x(cp -rp #{orig} #{dst}) + #%x(rm -rf #{ONE_LOG_LOCATION}/*) unless ONE_VAR_LOCATION == ONE_LOG_LOCATION + end + + def stop_one(ignore_wait_failure = true) + STDOUT.print "==> Stopping OpenNebula... " + STDOUT.flush + system('one stop 2>&1 > /dev/null') + STDOUT.puts "done" + + if RSpec.configuration.main_defaults[:manage_fireedge] + stop_fireedge(ignore_wait_failure) + end + + if RSpec.configuration.main_defaults[:manage_sunstone] + stop_sunstone(ignore_wait_failure) + end + end + + ############################################################################ + # Functions to manage Sunstone + ############################################################################ + + def start_sunstone + STDOUT.print "==> Starting Sunstone server... " + STDOUT.flush + + # alternative ways how to start Sunstone + begin + sunstone_start_cmds = RSpec.configuration.main_defaults[:sunstone_start_commands] + rescue StandardError => e + ensure + sunstone_start_cmds ||= [SUNSTONE_START] + end + + sunstone_ready = false + sunstone_start_cmds.each do |cmd| + stdout_str, stderr_str, status = Open3.capture3(cmd) + next unless status.success? + + sunstone_ready = true + + if cmd != SUNSTONE_START + # start VNC server + stdout_str, stderr_str, status = Open3.capture3(NOVNC_START) + sunstone_ready = status.success? + end + + break + end + + return false unless sunstone_ready + + wait_for_port_open(get_sunstone_url) + + STDOUT.puts "done" + end + + def stop_sunstone(ignore_wait_failure = true) + STDOUT.print "==> Stopping Sunstone server... " + STDOUT.flush + + # alternative ways how to stop Sunstone + begin + sunstone_stop_cmds = RSpec.configuration.main_defaults[:sunstone_stop_commands] + rescue StandardError => e + ensure + sunstone_stop_cmds ||= [SUNSTONE_STOP] + end + + if sunstone_stop_cmds.include? SUNSTONE_STOP + if !wait_for_port_open(get_sunstone_url) && !ignore_wait_failure + STDOUT.puts "not found, continuing." + return + end + end + + sunstone_done = false + sunstone_stop_cmds.each do |cmd| + stdout_str, stderr_str, status = Open3.capture3(cmd) + next unless status.success? + + if cmd == SUNSTONE_STOP + if !stderr_str.include? "VNC server is not running" + wait_loop({timeout: 150}) do + !is_port_open?('localhost', 9869) + end + end + else + # stop VNC server + stdout_str, stderr_str, status = Open3.capture3(NOVNC_STOP) + sunstone_done = status.success? + end + + sunstone_done = true + break + end + + return false unless sunstone_done + + STDOUT.puts "done" + end + + def is_port_open?(ip, port) + begin + Timeout::timeout(1) do + begin + s = TCPSocket.new(ip, port) + s.close + return true + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH + return false + end + end + rescue Timeout::Error + end + + return false + end + + def get_sunstone_url + # respect alternative Sunstone URL if specified + # in the microenv's defaults.yaml + begin + url = RSpec.configuration.main_defaults[:sunstone_url] + rescue StandardError => e + ensure + url ||= 'http://localhost:9869' + end + + fail 'Error: Bad URI for sunstone' if !valid_url?(url) + + return url + end + + def valid_url?(url) + uri = URI.parse(url) + (uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)) && !uri.host.nil? && !uri.port.nil? + rescue URI::InvalidURIError + false + end + + def wait_for_port_open(url) + uri = URI.parse(url) + host = uri.host + port = uri.port + + (0..10).each{ + sleep 1 + return if is_port_open?(host, port) + } + + return false + end + + ############################################################################ + # Functions to manage FireEdge + ############################################################################ + + def start_fireedge + STDOUT.print "==> Starting FireEdge server... " + STDOUT.flush + + # alternative ways how to start FireEdge + begin + fireedge_start_cmds = RSpec.configuration.main_defaults[:fireedge_start_commands] + rescue StandardError => e + ensure + fireedge_start_cmds ||= [FIREEDGE_START] + end + + fireedge_ready = false + fireedge_start_cmds.each do |cmd| + stdout_str, stderr_str, status = Open3.capture3(cmd) + + if status.success? + fireedge_ready = true + break + end + end + + return false unless fireedge_ready + + wait_for_port_open(get_fireedge_url) + + STDOUT.puts "done" + end + + def stop_fireedge(ignore_wait_failure = true) + STDOUT.print "==> Stopping FireEdge server... " + STDOUT.flush + + # alternative ways how to stop FireEdge + begin + fireedge_stop_cmds = RSpec.configuration.main_defaults[:fireedge_stop_commands] + rescue StandardError => e + ensure + fireedge_stop_cmds ||= [FIREEDGE_STOP] + end + + if fireedge_stop_cmds.include? FIREEDGE_STOP + if !wait_for_port_open(get_fireedge_url) && !ignore_wait_failure + STDOUT.puts "not found, continuing." + return + end + end + + fireedge_done = false + fireedge_stop_cmds.each do |cmd| + stdout_str, stderr_str, status = Open3.capture3(cmd) + next unless status.success? + + fireedge_done = true + break + end + + return false unless fireedge_done + + STDOUT.puts "done" + end + + def get_fireedge_url + # respect alternative FireEdge URL if specified + # in the microenv's defaults.yaml + begin + url = RSpec.configuration.main_defaults[:fireedge_url] + rescue StandardError => e + ensure + url ||= 'http://localhost:2616' + end + + fail 'Error: Bad URI for FireEdge' if !valid_url?(url) + + return url + end + + ############################################################################ + # Functions to manage OpenNebula database + ############################################################################ + def clean_db + case @one_conf['DB']['BACKEND'] + when 'sqlite' + STDOUT.puts "==> Cleaning sqlite DB" + clean_sqlite_db + when 'mysql' + STDOUT.puts "==> Cleaning mysql DB" + clean_mysql_db + else + STDERR.puts "==> Unknown DB backend" + end + end + + def is_sqlite? + @one_conf['DB']['BACKEND'] == 'sqlite' + end + + def clean_mysql_db + user = @one_conf['DB']['USER'] + pass = @one_conf['DB']['PASSWD'] + + dbname = @one_conf['DB']['DB_NAME'] + + cmd_str = "mysqladmin -f -p drop #{dbname} -u #{user} -p#{pass}" + + system(cmd_str) + + # Reset query cache otherwise new mysql (8.0.30) is unpredictable + system('sudo mysql -e "RESET QUERY CACHE"') + end + + def clean_sqlite_db + cmd_str = "rm -f #{ONE_VAR_LOCATION}/one.db > /dev/null 2>&1" + + system(cmd_str) + end + + def backup_db + case @one_conf['DB']['BACKEND'] + when 'sqlite' + STDOUT.puts "==> Doing backup sqlite DB" + + cmd_str = "onedb backup -s #{ONE_VAR_LOCATION}/one.db" + when /^mysql$/ + STDOUT.puts "==> Doing backup mysql/psql DB" + + user = @one_conf['DB']['USER'] + pass = @one_conf['DB']['PASSWD'] + + dbname = @one_conf['DB']['DB_NAME'] + + cmd_str = "onedb backup -u #{user} -p #{pass} -d #{dbname}" + else + STDERR.puts "==> Unknown DB backend" + return false + end + + backup_file = "" + IO.popen(cmd_str) do |return_str| + out = return_str.read + backup_file = out[/stored in (.*?)\nUse/m, 1] + end + + return backup_file + end + + def restore_db(backup_file) + case @one_conf['DB']['BACKEND'] + when 'sqlite' + STDOUT.puts "==> Restoring sqlite DB" + + cmd_str = "onedb restore -f -s #{ONE_VAR_LOCATION}/one.db #{backup_file}" + + system(cmd_str) + when /^mysql$/ + STDOUT.puts "==> Restoring mysql/psql DB" + + user = @one_conf['DB']['USER'] + pass = @one_conf['DB']['PASSWD'] + + dbname = @one_conf['DB']['DB_NAME'] + + cmd_str = "onedb restore -f -u #{user} -p #{pass} -d #{dbname} #{backup_file}" + + system(cmd_str) + else + STDERR.puts "==> Unknown DB backend" + end + end + + def upgrade_db + case @one_conf['DB']['BACKEND'] + when 'sqlite' + STDOUT.puts "==> Doing upgrade sqlite DB" + + cmd_str = "onedb upgrade -v --no-backup -s #{ONE_VAR_LOCATION}/one.db" + when /^mysql$/ + STDOUT.puts "==> Doing upgrade mysql/psql DB" + + user = @one_conf['DB']['USER'] + pass = @one_conf['DB']['PASSWD'] + + dbname = @one_conf['DB']['DB_NAME'] + + cmd_str = "onedb upgrade -v --no-backup -u #{user} -p #{pass} -d #{dbname}" + else + STDERR.puts "==> Unknown DB backend" + return false + end + + system(cmd_str) + end + + def version_db + case @one_conf['DB']['BACKEND'] + when 'sqlite' + STDOUT.puts "==> Getting version sqlite DB" + + cmd_str = "onedb version -s #{ONE_VAR_LOCATION}/one.db" + when /^mysql$/ + STDOUT.puts "==> Getting version mysql/psql DB" + + user = @one_conf['DB']['USER'] + pass = @one_conf['DB']['PASSWD'] + + dbname = @one_conf['DB']['DB_NAME'] + + cmd_str = "onedb version -u #{user} -p #{pass} -d #{dbname}" + else + STDERR.puts "==> Unknown DB backend" + return false + end + + out = "" + IO.popen(cmd_str) do |return_str| + out = return_str.read + end + + return out[/Shared: (.*?)\n/m, 1], out[/Local: (.*?)\n/m, 1] + end + + ############################################################################ + # Functions to manage OpenNebula var state + ############################################################################ + def clean_var + STDOUT.puts "==> Cleaning #{ONE_VAR_LOCATION}" + + 3.times do |i| + ds_dir = "#{ONE_VAR_LOCATION}/datastores/#{i}" + rm_cmd = "find #{ds_dir} -mindepth 1 -delete > /dev/null 2>&1" + + system(rm_cmd) + end + + rm_cmd = "find #{ONE_VAR_LOCATION}/datastores/10* -maxdepth 0 "\ + "-exec rm -rf \{\} + > /dev/null 2>&1" + system(rm_cmd) + + rm_cmd = "rm -rf #{ONE_VAR_LOCATION}/vms/* > /dev/null 2>&1" + system(rm_cmd) + + rm_cmd ="find #{ONE_VAR_LOCATION}/.one -type f ! -name 'one_auth' -delete 2>&1" + system(rm_cmd) + end + + def clean_oned_log + # Wait for oned to shut down + system("mv #{ONE_LOG_LOCATION}/oned.log #{ONE_LOG_LOCATION}/oned-sunstonetests.log") + end + + ############################################################################ + # This functions writes a hash to a string using OpenNebula Template syntax + ############################################################################ + def render_template_value(str, value) + if value.class == Hash + str << "=[\n" + + str << value.collect do |k, v| + next if !v || v.empty? + + ' ' + k.to_s.upcase + '=' + "\"#{v.to_s}\"" + end.compact.join(",\n") + + str << "\n]\n" + elsif value.class == String + str << "= \"#{value.to_s}\"\n" + end + end + + def to_template(attributes) + str = attributes.collect do |key, value| + next if !value || value.empty? + + str_line="" + + if value.class==Array + value.each do |v| + str_line << key.to_s.upcase + render_template_value(str_line, v) + end + else + str_line << key.to_s.upcase + render_template_value(str_line, value) + end + + str_line + end.compact.join('') + + str + end +end diff --git a/apps-code/community-apps/appliances/lib/community/defaults.yaml b/apps-code/community-apps/appliances/lib/community/defaults.yaml new file mode 100644 index 0000000..63a560e --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/defaults.yaml @@ -0,0 +1,20 @@ +--- +#:debug: true +:rspec_disable_logs_on_error: true +:template: base +:default_url: http://services/images/ +:oneadmin: oneadmin +:hosts: + - '%HOST0%' +:xpath_pub_nic: "TEMPLATE/NIC[1]" +:ec2_hostname: + :name: "mxfun-vbx02-vsrxm066" + :domain: "novalocal" + +# network used for NIC (alias) attach tests +:network_attach: 'private' + +# check DNS is configured (not trying to resolve on its own via resolved) +:resolve_names: + - services + - www.google.com diff --git a/apps-code/community-apps/appliances/lib/community/tests.md b/apps-code/community-apps/appliances/lib/community/tests.md new file mode 100644 index 0000000..06a926d --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/tests.md @@ -0,0 +1,200 @@ +# Certification tests + +The appliance contribution relies on the one-apps framework. That is, you provide some packer scripts that build a an appliance with certain functionality. When contributing an appliance, you must provide two separate logics and verify the two processes for each of them work as intended. + +The contribution is composed by two requirements + +1. The first one is to provide the code that generates your appliance using the one-apps framework. The process is described in detail on the [one-apps wiki dev section](https://github.com/OpenNebula/one-apps/wiki/tool_dev#creating-a-new-appliance). You can take a look also at [a webinar](https://www.youtube.com/watch?v=UstX_KyOi0k) that showcases this process in detail. +2. The second one is to provide a set of tests that verify the functionality provided by your appliance. + 1. These tests are also code that must be provided by you together with the appliance logic. + 2. Besides this, the appliance must comply with our internal test suite that verifies the Virtual Machine image works properly with the contextualization packages. You do not need to write tests for this, just some metadata describing the base OS used for the image. + +## How to create your own tests + +The tests are built around [rspec](https://rspec.info/). You describe certain tests, called examples, and define conditions where the test fails or succeeds based on expectations. The tests are assumed to be executed in the OpenNebula frontend. This means the `opennebula` systemd unit runs in the same host where the tests are executed. + +We are going to showcase the contribution process with database appliance showcased on the [one-apps webinar](https://www.youtube.com/watch?v=UstX_KyOi0k) + +### App structure + +The application logic resides at `./appliances/`. Within that directory resides also the appliance metadata and the `tests` directory. + +For example + +``` +appliances/example/ +├── appliance.sh # appliance logic +├── context.yaml # generated after the tests are executed based on metadata +├── metadata.yaml # appliance metadata used in testing +├── tests +│   └── 00-example_basic.rb +└── tests.yaml # list of test files to be executed +``` + +The file `00-example_basic.rb` contains some tests that verify the mysql database within the Virtual Machine. + +### Example tests + +The appliance provides the following custom contextualization parameters + +```bash +ONEAPP_DB_NAME # Database name +ONEAPP_DB_USER # Database service user +ONEAPP_DB_PASSWORD # Database service password +ONEAPP_DB_ROOT_PASSWORD # Database password for root +``` + +The tests basically verify that using these parameters is working as intended. + +In this case 6 tests are performed using rspec `it` blocks: +- mysql is installed. + - This verifies that the app built correctly with the required software + - The app could have successfully built, but failed to perform some install tasks +- mysql is running +- one-apps service framework reports the app as ready + - every time the the VM containing the app starts, the service within, in this case mysql, will be reconfigured according to what is stated in the CONTEXT section. + - one-apps will trigger the configuration and if everything goes well, it will report ready + +To run the tests, `cd` into the directory `./appliances/lib/community` and then execute `./app_readiness.rb `. + +Using this example + +``` +root@PC04:/opt/one-apps/appliances/lib/community# ./app_readiness.rb example +Appliance Certification + mysql is installed + mysql service is running +"\n" + +" ___ _ __ ___\n" + +" / _ \\ | '_ \\ / _ \\ OpenNebula Service Appliance\n" + +" | (_) || | | || __/\n" + +" \\___/ |_| |_| \\___|\n" + +"\n" + +" All set and ready to serve 8)\n" + +"\n" + check one-apps motd + can connect as root with defined password + database exists + can connect as user with defined password + +Finished in 1 minute 10.07 seconds (files took 0.20889 seconds to load) +6 examples, 0 failures + +``` + +Only the tests defined at `tests.yaml` will be executed. With this you can define multiple test files to verify independent workflows and also test them separately. + +```yaml +--- +- '00-example_basic.rb' +``` + +### Creating rspec example groups from scratch + +In order to develop your test, you will need to create your example group(s). + +Taking a look at the file `00-example_basic.rb` we have the group `Appliance Certification` with 6 examples. Each example is an `it` block. Within the blocks there is some regular code and some code that *checks expectations*. An example of this special code is + +```ruby +expect(execution.exitstatus).to eq(0) +expect(execution.stdout).to include('All set and ready to serve') +``` + +The test in this case succeeds provided that a command runs without errors and its output contains a string. Both are required to pass the test. + + +Here is an example that does nothing useful, yet still runs + +```ruby +# /tmp/asdf.rb file +describe 'Useless test' do + it 'Checks running state' do + running = true + expect(running).to be(true) + end + + it 'Checks state' do + status = 'running' + expect(status).to eql('amazing') # will fail + end +end +``` + +If you run this with `rspec -f d /tmp/asdf.rb`, you'll get + +``` +Useless test + Checks running state + Checks state (FAILED - 1) + +Failures: + + 1) Useless test Checks state + Failure/Error: expect(status).to eql('amazing') + + expected: "amazing" + got: "running" + + (compared using eql?) + # /tmp/asdf.rb:9:in `block (2 levels) in ' + +Finished in 0.01369 seconds (files took 0.09474 seconds to load) +2 examples, 1 failure + +Failed examples: + +rspec /tmp/asdf.rb:7 # Useless test Checks state +``` + +Now you need to make your tests useful. For this we provide some libraries to aid you. If you notice, in the mysql test file there are some calls that reference remote command execution in VMs. However we never create a VM in this code. This file uses the `app_handler.rb` library, which takes care of this. You can use this library to abstract from the complexity of creating and destroying a VM with custom context parameters. + +To use it you need to do the following on the rspec file at `appliances//tests/.rb` + +- load the library with `require_relative ../../lib/community/app_handler` +- load the library example group within your example group with `include_context('vm_handler')` + +Once you do that, the VM instance will be stored in the variable `@info[:vm]`. You can execute commands in there with `ssh` instance method. The execution will return as an object where you can inspect parameters like the `existstaus, stderr and stdout`. + +For an in-depth look at what you can do with the VM, please take a look at the file `appliances/lib/community/clitester/VM.rb`. This class abstracts lots of operations performed to a VM via CLI. + +Now these tests assume certain conditions on the host running OpenNebula. +- a virtual network where the test VMs will run +- a VM Template with + - this virtual network + - no disk. The disk will be passed dynamically as your app is built + - `SSH_PUBLIC_KEY="$USER[SSH_PUBLIC_KEY]"` +- The tests are expected to run as the oneadmin user +- The oneadmin user must be able reach these VMs. You have to set the SSH_PUBLIC_KEY on the user template + +You can use [one-deploy](https://github.com/OpenNebula/one-deploy) to quickly create a compatible test scenario. A simple node containing both the frontend and a kvm node will do. An inventory file is provided as a reference at `appliances/lib/community/ansible/inventory.yaml` + +Lastly you have to define a `metadata.yaml` file. This describes the appliance, showcasing information like the CONTEXT Params used to control the App and the Linux distro used. + +```yaml +--- +:app: + :name: service_example # name used to make the app with the makefile + :type: service # there are service (complex apps) and distro (base apps) + :os: + :type: linux # linux, freebsd or windows + :base: alma8 # distro where the app runs on + :hypervisor: KVM + :context: # which context params are used to control the app + :prefixed: true # params are prefixed with ONEAPP_ on the appliance logic ex. ONEAPP_DB_NAME + :params: + :DB_NAME: 'dbname' + :DB_USER: 'username' + :DB_PASSWORD: 'upass' + :DB_ROOT_PASSWORD: 'arpass' + +:one: + :datastore_name: default # target datatore to import the one-apps produced image + :timeout: '90' # timeout for XMLRPC calls + +:infra: + :disk_format: qcow2 # one-apps built image disk format + :apps_path: /opt/one-apps/export # directory where one-apps exports the appliances to + +``` + +After executing the tests, the `context.yaml` file is generated. This file should be included in the Pull Request as well. We will use it to pass the `context` tests in our infrastructure. diff --git a/apps-code/community-apps/appliances/lib/functions.sh b/apps-code/community-apps/appliances/lib/functions.sh new file mode 100644 index 0000000..897f896 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/functions.sh @@ -0,0 +1,406 @@ +# ---------------------------------------------------------------------------- # +# Copyright 2018-2019, OpenNebula Project, OpenNebula Systems # +# # +# Licensed under the Apache License, Version 2.0 (the "License"); you may # +# not use this file except in compliance with the License. You may obtain # +# a copy of the License at # +# # +# http://www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +# ---------------------------------------------------------------------------- # + +# args: "$@" +_parse_arguments() +{ + _ACTION=nil + state=nil + while [ -n "$1" ] ; do + case "$state" in + nil) + case "$1" in + -h|--help|help) + _ACTION=help + state=done + ;; + install) + _ACTION=install + state=install + ;; + configure|bootstrap) + _ACTION="$1" + state=configure + ;; + *) + _ACTION=badargs + msg unknown "BAD USAGE: unknown argument: $1" + break + ;; + esac + ;; + configure) + case "$1" in + reconfigure) + ONE_SERVICE_RECONFIGURE=true + state=done + ;; + *) + _ACTION=badargs + msg unknown "BAD USAGE: unknown argument: $1" + break + ;; + esac + ;; + install) + ONE_SERVICE_VERSION="$1" + state=done + ;; + done) + _ACTION=badargs + msg unknown "BAD USAGE: extraneous argument(s)" + break + ;; + esac + shift + done +} + +# args: "$0" "${@}" +_lock_or_fail() +{ + this_script="$1" + if [ "${_SERVICE_LOCK}" != "$this_script" ] ; then + exec env _SERVICE_LOCK="$this_script" flock -xn $this_script "$@" + fi +} + +_on_exit() +{ + # this is the exit handler - I want to clean up as much as I can + set +e + + # first do whatever the service appliance needs to clean after itself + service_cleanup + + # delete temporary working file(s) + if [ -n "$_SERVICE_LOG_PIPE" ] ; then + rm -f "$_SERVICE_LOG_PIPE" + fi + + # exiting while the stage was interrupted - change status to failure + _status=$(_get_current_service_result) + case "$_status" in + started) + _set_service_status failure + ;; + esac + + # all done - delete pid file and exit + rm -f "$ONE_SERVICE_PIDFILE" +} + +_trap_exit() +{ + trap '_on_exit 2>/dev/null' INT QUIT TERM EXIT +} + +_is_running() +{ + pid=$(_get_pid) + + if echo "$pid" | grep -q '^[0-9]\+$' ; then + kill -0 $pid + return $? + fi + + return 1 +} + +_get_pid() +{ + if [ -f "$ONE_SERVICE_PIDFILE" ] ; then + cat "$ONE_SERVICE_PIDFILE" + fi +} + +_write_pid() +{ + echo $$ > "$ONE_SERVICE_PIDFILE" +} + +_get_service_status() +{ + if [ -f "$ONE_SERVICE_STATUS" ] ; then + cat "$ONE_SERVICE_STATUS" + fi +} + +_get_current_service_step() +{ + _get_service_status | sed -n 's/^\(install\|configure\|bootstrap\)_.*/\1/p' +} + +_get_current_service_result() +{ + _result=$(_get_service_status | sed -n 's/^\(install\|configure\|bootstrap\)_\(.*\)/\2/p') + case "$_result" in + started|success|failure) + echo "$_result" + ;; + esac +} + +# arg: install|configure|bootstrap [| +_check_service_status() +{ + _reconfigure="$2" + + case "$1" in + install) + case "$(_get_service_status)" in + '') + # nothing was done so far + return 0 + ;; + install_success) + msg warning "Installation was already done - skip" + return 1 + ;; + install_started) + msg error "Installation was probably interrupted - abort" + _set_service_status failure + exit 1 + ;; + install_failure) + msg error "Last installation attempt failed - abort" + exit 1 + ;; + *) + msg error "Install step cannot be run - go check: ${ONE_SERVICE_STATUS}" + exit 1 + ;; + esac + ;; + configure) + case "$(_get_service_status)" in + '') + # nothing was done so far - missing install + msg error "Cannot proceed with configuration - missing installation step" + exit 1 + ;; + install_success) + # installation was successfull - can continue + return 0 + ;; + configure_success) + if is_true _reconfigure ; then + msg info "Starting reconfiguration of the service" + return 0 + else + msg warning "Configuration was already done - skip" + return 1 + fi + ;; + configure_started) + if is_true _reconfigure ; then + msg info "Starting reconfiguration of the service" + return 0 + else + msg error "Configuration was probably interrupted - abort" + _set_service_status failure + exit 1 + fi + ;; + configure_failure) + if is_true _reconfigure ; then + msg info "Starting reconfiguration of the service" + return 0 + else + msg error "Last configuration attempt failed - abort" + exit 1 + fi + ;; + bootstrap*) + if is_true _reconfigure ; then + msg info "Starting reconfiguration of the service" + return 0 + else + msg error "Configure step will not run since the appliance is set as non-reconfigurable. Chech the value of ONE_SERVICE_RECONFIGURABLE" + exit 1 + fi + ;; + *) + msg error "Configure step cannot be run - go check: ${ONE_SERVICE_STATUS}" + exit 1 + ;; + esac + ;; + bootstrap) + case "$(_get_service_status)" in + '') + # nothing was done so far - missing install + msg error "Cannot proceed with bootstrapping - missing installation step" + exit 1 + ;; + configure_success) + # configuration was successfull - can continue + return 0 + ;; + bootstrap_success) + if is_true _reconfigure ; then + msg info "Redo bootstrap of the service" + return 0 + else + msg warning "Bootstrap was already done - skip" + return 1 + fi + ;; + bootstrap_started) + if is_true _reconfigure ; then + msg info "Redo bootstrap of the service" + return 0 + else + msg error "Bootstrap was probably interrupted - abort" + _set_service_status failure + exit 1 + fi + ;; + bootstrap_failure) + if is_true _reconfigure ; then + msg info "Redo bootstrap of the service" + return 0 + else + msg error "Last bootstrap attempt failed - abort" + exit 1 + fi + ;; + *) + msg error "Bootstrap step cannot be run - go check: ${ONE_SERVICE_STATUS}" + exit 1 + ;; + esac + ;; + esac + + msg error "THIS SHOULD NOT HAPPEN!" + msg unknown "Possibly a bug, wrong usage, action etc." + exit 1 +} + +# arg: install|configure|bootstrap|success|failure +_set_service_status() +{ + _status="$1" + case "$_status" in + install|configure|bootstrap) + echo ${_status}_started > "$ONE_SERVICE_STATUS" + _set_motd "$_status" started + ;; + success|failure) + _step=$(_get_current_service_step) + echo ${_step}_${_status} > "$ONE_SERVICE_STATUS" + _set_motd "$_step" "$_status" + ;; + *) + msg unknown "THIS SHOULD NOT HAPPEN!" + msg unknown "Possibly a bug, wrong usage, action etc." + exit 1 + ;; + esac +} + +_print_logo() +{ + cat > ${ONE_SERVICE_MOTD} <> ${ONE_SERVICE_MOTD} <> ${ONE_SERVICE_MOTD} <> ${ONE_SERVICE_MOTD} <> ${ONE_SERVICE_MOTD} < +_start_log() +{ + _logfile="$1" + _SERVICE_LOG_PIPE="$ONE_SERVICE_LOGDIR"/one_service_log.pipe + + # create named pipe + mknod "$_SERVICE_LOG_PIPE" p + + # connect tee to the pipe and let it write to the log and screen + tee <"$_SERVICE_LOG_PIPE" -a "$_logfile" & + + # save stdout to fd 3 and force shell to write to the pipe + exec 3>&1 >"$_SERVICE_LOG_PIPE" +} + +_end_log() +{ + # restore stdout for the shell and close fd 3 + exec >&3 3>&- +} diff --git a/apps-code/community-apps/appliances/lib/helpers.rb b/apps-code/community-apps/appliances/lib/helpers.rb new file mode 100644 index 0000000..2e554f8 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/helpers.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +require 'base64' +require 'fileutils' +require 'ipaddr' +require 'json' +require 'logger' +require 'open3' +require 'socket' + +LOGGER_STDOUT = Logger.new(STDOUT) +LOGGER_STDERR = Logger.new(STDERR) + +LOGGERS = { + info: LOGGER_STDOUT.method(:info), + debug: LOGGER_STDERR.method(:debug), + warn: LOGGER_STDERR.method(:warn), + error: LOGGER_STDERR.method(:error) +}.freeze + +def msg(level, string) + LOGGERS[level].call string +end + +def env(name, default) + value = ENV.fetch name.to_s, '' + value = value.empty? ? default : value + value = %w[YES 1].include?(value.upcase) if default.instance_of?(String) && %w[YES NO].include?(default.upcase) + value +end + +def load_env(path = '/run/one-context/one_env') + replacements = { + "\n" => '\n' + }.tap do |h| + h.default_proc = ->(h, k) { k } + end + + # NOTE: We must allow literal newline characters in values as + # OpenNebula separates multiple ssh-rsa entries with + # literal newlines! + folded = Enumerator.new do |y| + cached = [] + + yield_prev_line = -> do + unless cached.empty? + y << cached.join.gsub(/./m, replacements) + cached = [] + end + end + + File.read(path).lines.each do |line| + yield_prev_line.call if line =~ /^export [^=]+="/ + cached << line + end + + yield_prev_line.call + end + + folded.each do |line| + # Everything to the right of the last " is discarded! + next unless line =~ /^export ([^=]+)=(".*")[^"]*$/ + + ENV[$1] = $2.undump + end +end + +def slurp(path) + Base64.encode64(File.read(path)).lines.map(&:strip).join +end + +def file(path, content, owner: nil, group: nil, mode: 'u=rw,go=r', overwrite: false) + return if !overwrite && File.exist?(path) + + FileUtils.mkdir_p File.dirname path + + File.write path, content + + FileUtils.chown owner, group, path unless owner.nil? || group.nil? + + FileUtils.chmod mode, path +end + +def bash(script, chomp: false, terminate: false) + command = 'exec /bin/bash --login -s' + + stdin_data = <<~SCRIPT + set -o errexit -o nounset -o pipefail + set -x + #{script} + SCRIPT + + stdout, stderr, status = Open3.capture3 command, stdin_data: stdin_data + unless status.exitstatus.zero? + error_message = "#{status.exitstatus}: #{stderr}" + msg :error, error_message + + raise error_message unless terminate + + exit status.exitstatus + end + + chomp ? stdout.chomp : stdout +end + +def ipv4?(string) + string.is_a?(String) && IPAddr.new(string) ? true : false +rescue IPAddr::InvalidAddressError + false +end + +def integer?(string) + Integer(string) ? true : false +rescue ArgumentError + false +end + +alias port? integer? + +def tcp_port_open?(ipv4, port, seconds = 5) + # > If a block is given, the block is called with the socket. + # > The value of the block is returned. + # > The socket is closed when this method returns. + Socket.tcp(ipv4, port, connect_timeout: seconds) {} + true +rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ETIMEDOUT + false +end + +def hashmap + def recurse(a, b, g) + return a.method(g.next).call(b) { |_, a, b| recurse(a, b, g) } if a.is_a?(Hash) && b.is_a?(Hash) + return b + end + + # USAGE: c = hashmap.combine a, b + def combine(a, b) + recurse(a, b, Enumerator.new { |y| loop { y << :merge } }) + end + + # USAGE: hashmap.combine! a, b + def combine!(a, b) + recurse(a, b, Enumerator.new { |y| y << :merge!; loop { y << :merge } }) + end +end + +def sortkeys + def apply(method, keys, pattern) + k_unsorted = keys.select do |k| + k =~ pattern + end + + k_sorted = k_unsorted.sort_by do |k| + k =~ pattern + Gem::Version.new $~[1..(-1)].join(%[.]) + end + + k_map = k_unsorted.zip(k_sorted).to_h + + keys.method(method).call do |x| + (y = k_map[x]).nil? ? x : y + end + end + + def as_version(keys, pattern: /^(\d+)[.](\d+)[.](\d+)$/) + apply :map, keys, pattern + end + + def as_version!(keys, pattern: /^(\d+)[.](\d+)[.](\d+)$/) + apply :map!, keys, pattern + end +end + +def sorted_deps(deps) + # NOTE: This doesn't handle circular dependencies. + + # Work with string keys only. + d = deps.to_h { |k, v| [k.to_s, v.map(&:to_s)] } + + def recurse(d, x, level = 0) + # The distance is at least the same as the current level. + distance = level + + # Recurse down each branch and record the longest distance to the root. + d[x].each { |y| distance = [distance, recurse(d, y, level + 1)].max } + + distance + end + + deps.keys.map { |k| [k, recurse(d, k.to_s)] } # compute the longest distance + .sort_by(&:last) # sort by the distance + .map(&:first) # return sorted keys (original) +end + +# install|configure|bootstrap started|success|failure +def set_motd(step, status, path = '/etc/motd') + header_txt = <<~'HEADER' + . + ___ _ __ ___ + / _ \ | '_ \ / _ \ OpenNebula Service Appliance + | (_) || | | || __/ + \___/ |_| |_| \___| + + HEADER + + step_txt = \ + case step.to_sym + when :install then '1/3 Installation' + when :configure then '2/3 Configuration' + when :bootstrap then '3/3 Bootstrap' + end + + status_txt = \ + case status.to_sym + when :started then <<~STARTED + #{header_txt} + #{step_txt} step is in progress... + + * * * * * * * * + * PLEASE WAIT * + * * * * * * * * + + STARTED + when :success then if step.to_sym == :bootstrap + <<~SUCCESS + #{header_txt} + All set and ready to serve 8) + + SUCCESS + else + <<~SUCCESS + #{header_txt} + #{step_txt} step was successfull. + + SUCCESS + end + when :failure then <<~FAILURE + #{header_txt} + #{step_txt} step failed. + + * * * * * * * * * * + * APPLIANCE ERROR * + * * * * * * * * * * + + Read documentation and try to redeploy! + + FAILURE + end + + file path, status_txt.delete_prefix('.'), mode: 'u=rw,go=r', overwrite: true +end + +# install|configure|bootstrap|success|failure +def set_status(status, path = '/etc/one-appliance/status') + case status.to_sym + when :install, :configure, :bootstrap + file path, <<~STATUS, mode: 'u=rw,go=r', overwrite: true + #{status.to_s}_started + STATUS + set_motd status, :started + when :success, :failure + step = File.open(path, &:gets).strip.split('_').first + file path, <<~STATUS, mode: 'u=rw,go=r', overwrite: true + #{step}_#{status.to_s} + STATUS + set_motd step, status + end +end diff --git a/apps-code/community-apps/appliances/lib/tests.rb b/apps-code/community-apps/appliances/lib/tests.rb new file mode 100644 index 0000000..5ed31bd --- /dev/null +++ b/apps-code/community-apps/appliances/lib/tests.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require 'rspec' +require 'tmpdir' +require_relative 'helpers.rb' + +RSpec.describe 'load_env' do + it 'should load env vars from file' do + tests = [ + [ { :E1 => 'V1', + :E2 => 'V2', + :E3 => 'V3' }, + <<~INPUT + export E1="V1" + export E2="V2" + export E3="V3" + INPUT + ], + [ { :E1 => '"', + :E2 => "\n", + :E3 => "\\n" }, + <<~'INPUT' + export E1="\"" + export E2="\n" + export E3="\\n" + INPUT + ], + [ { :E1 => "A\nB\nC", + :E2 => "A\nB\nC" }, + <<~'INPUT' + export E1="A + B + C" + export E2="A + B\nC" + INPUT + ], + [ { :E1 => "\nA\nB\n", + :E2 => "\nA\nB", + :E3 => "A\n\nB\n" }, + <<~'INPUT' + + export E1=" + A + B + " + + export E2=" + A + B" + + export E3="A + + B + " + + INPUT + ] + ] + Dir.mktmpdir do |dir| + tests.each do |output, input| + File.write "#{dir}/one_env", input + load_env "#{dir}/one_env" + output.each do |k, v| + expect(ENV[k.to_s]).to eq v + end + end + end + end +end + +RSpec.describe 'bash' do + it 'should raise' do + allow(self).to receive(:exit).and_return nil + expect { bash 'false' }.to raise_error(RuntimeError) + end + it 'should not raise' do + allow(self).to receive(:exit).and_return nil + expect { bash 'false', terminate: true }.not_to raise_error + end +end + +RSpec.describe 'ipv4?' do + it 'should evaluate to true' do + ipv4s = %w[ + 10.11.12.13 + 10.11.12.13/24 + 10.11.12.13/32 + 192.168.144.120 + ] + ipv4s.each do |item| + expect(ipv4?(item)).to be true + end + end + it 'should evaluate to false' do + ipv4s = %w[ + 10.11.12 + 10.11.12. + 10.11.12.256 + asd.168.144.120 + 192.168.144.96-192.168.144.120 + ] + ipv4s.each do |item| + expect(ipv4?(item)).to be false + end + end +end + +RSpec.describe 'hashmap' do + tests = [ + [ [{}, {}], {} ], + + [ [{a: 1}, {b: 2}], {a: 1, b: 2} ], + + [ [{a: 1, b: 3}, {b: 2}], {a: 1, b: 2} ], + + [ [{a: 1, b: 2}, {b: []}], {a: 1, b: []} ], + + [ [{a: 1, b: [:c]}, {b: []}], {a: 1, b: []} ], + + [ [{a: 1, b: {c: 3, d: 3}}, {b: {c: 2, e: 4}}], {a: 1, b: {c: 2, d: 3, e: 4}} ] + ] + it 'should recursively combine two hashmaps' do + tests.each do |(a, b), c| + expect(hashmap.combine(a, b)).to eq c + end + end + it 'should recursively combine two hashmaps (in-place)' do + tests.each do |(a, b), c| + hashmap.combine!(a, b) + expect(a).to eq c + end + end +end + +RSpec.describe 'sortkeys' do + it 'should v-sort according to a pattern' do + tests = [ + [ %w[ETH1_VIP10 Y ETH1_VIP1 X ETH0_VIP0], + /^ETH(\d+)_VIP(\d+)$/, + %w[ETH0_VIP0 Y ETH1_VIP1 X ETH1_VIP10] ], + + [ %w[lo eth10 eth0 eth1 eth2], + /^eth(\d+)$/, + %w[lo eth0 eth1 eth2 eth10] ], + ] + tests.each do |input, pattern, output| + expect(sortkeys.as_version(input, pattern: pattern)).to eq output + sortkeys.as_version!(input, pattern: pattern) + expect(input).to eq output + end + end +end + +RSpec.describe 'sorted_deps' do + it 'should sort dependencies' do + tests = [ + [ { :a => [:b], + :b => [:c], + :c => [:d], + :d => [] }, [:d, :c, :b, :a] ], + + [ { :d => [:b], + :c => [:b, :d], + :b => [:a], + :a => [] }, [:a, :b, :d, :c] ], + + [ + { + :Failover => [:Keepalived], + :NAT4 => [:Failover, :Router4], + :Keepalived => [], + :Router4 => [:Failover] + }, + [ + :Keepalived, + :Failover, + :Router4, + :NAT4 + ] + ] + ] + tests.each do |input, output| + expect(sorted_deps(input)).to eq output + end + end +end + +RSpec.describe 'set_motd' do + it 'should render motd' do + output = <<~'OUTPUT' + . + ___ _ __ ___ + / _ \ | '_ \ / _ \ OpenNebula Service Appliance + | (_) || | | || __/ + \___/ |_| |_| \___| + + + All set and ready to serve 8) + + OUTPUT + Dir.mktmpdir do |dir| + set_motd :bootstrap, :success, "#{dir}/motd" + result = File.read "#{dir}/motd" + expect(result).to eq output.delete_prefix('.') + end + end +end + +RSpec.describe 'set_status' do + it 'should set status' do + allow(self).to receive(:set_motd).and_return nil + tests = [ + [ :install, 'install_started' ], + [ :success, 'install_success' ], + [ :configure, 'configure_started' ], + [ :success, 'configure_success' ], + [ :bootstrap, 'bootstrap_started' ], + [ :failure, 'bootstrap_failure' ] + ] + Dir.mktmpdir do |dir| + tests.each do |input, output| + set_status input, "#{dir}/status" + result = File.open("#{dir}/status", &:gets).strip + expect(result).to eq output + end + end + end +end diff --git a/apps-code/community-apps/appliances/lib/tests.sh b/apps-code/community-apps/appliances/lib/tests.sh new file mode 100755 index 0000000..819172f --- /dev/null +++ b/apps-code/community-apps/appliances/lib/tests.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -eu -o pipefail; shopt -qs failglob + +find . -type f -name 'tests.rb' | while read FILE; do + (cd $(dirname "$FILE")/ && echo ">> $FILE <<" && rspec $(basename "$FILE")) +done From 7fbbdb3bf5e4d4633549f0010b7af4bd75c1c67f Mon Sep 17 00:00:00 2001 From: ArnauGabrielAtienza Date: Thu, 9 Jan 2025 10:29:44 +0100 Subject: [PATCH 02/12] Add example Signed-off-by: ArnauGabrielAtienza --- .../appliances/example/appliance.sh | 287 ++++++++++++++++++ .../appliances/example/context.yaml | 10 + .../appliances/example/metadata.yaml | 43 +++ .../appliances/example/tests.yaml | 2 + .../example/tests/00-example_basic.rb | 103 +++++++ .../service_example/81-configure-ssh.sh | 29 ++ .../service_example/82-configure-context.sh | 14 + .../packer/service_example/example.pkr.hcl | 128 ++++++++ .../packer/service_example/gen_context | 33 ++ .../packer/service_example/variables.pkr.hcl | 22 ++ 10 files changed, 671 insertions(+) create mode 100644 apps-code/community-apps/appliances/example/appliance.sh create mode 100644 apps-code/community-apps/appliances/example/context.yaml create mode 100644 apps-code/community-apps/appliances/example/metadata.yaml create mode 100644 apps-code/community-apps/appliances/example/tests.yaml create mode 100644 apps-code/community-apps/appliances/example/tests/00-example_basic.rb create mode 100644 apps-code/community-apps/packer/service_example/81-configure-ssh.sh create mode 100644 apps-code/community-apps/packer/service_example/82-configure-context.sh create mode 100644 apps-code/community-apps/packer/service_example/example.pkr.hcl create mode 100755 apps-code/community-apps/packer/service_example/gen_context create mode 100644 apps-code/community-apps/packer/service_example/variables.pkr.hcl diff --git a/apps-code/community-apps/appliances/example/appliance.sh b/apps-code/community-apps/appliances/example/appliance.sh new file mode 100644 index 0000000..21833e2 --- /dev/null +++ b/apps-code/community-apps/appliances/example/appliance.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash + +# This script contains an example implementation logic for your appliances. +# For this example the goal will be to have a "database as a service" appliance + +### MariaDB ########################################################## + +# For organization purposes is good to define here variables that will be used by your bash logic +MARIADB_CREDENTIALS=/root/.my.cnf +MARIADB_CONFIG=/etc/my.cnf.d/example.cnf +PASSWORD_LENGTH=16 +ONE_SERVICE_SETUP_DIR="/opt/one-appliance" ### Install location. Required by bash helpers + +### CONTEXT SECTION ########################################################## + +# List of contextualization parameters +# This is how you interact with the appliance using OpenNebula. +# These variables are defined in the CONTEXT section of the VM Template as custom variables +# https://docs.opennebula.io/6.8/management_and_operations/references/template.html#context-section +ONE_SERVICE_PARAMS=( + 'ONEAPP_DB_NAME' 'configure' 'Database name' '' + 'ONEAPP_DB_USER' 'configure' 'Database service user' '' + 'ONEAPP_DB_PASSWORD' 'configure' 'Database service password' '' + 'ONEAPP_DB_ROOT_PASSWORD' 'configure' 'Database password for root' '' +) +# Default values for when the variable doesn't exist on the VM Template +ONEAPP_DB_NAME="${ONEAPP_DB_NAME:-mariadb}" +ONEAPP_DB_USER="${ONEAPP_DB_USER:-mariadb}" +ONEAPP_DB_PASSWORD="${ONEAPP_DB_PASSWORD:-$(gen_password ${PASSWORD_LENGTH})}" +ONEAPP_DB_ROOT_PASSWORD="${ONEAPP_DB_ROOT_PASSWORD:-$(gen_password ${PASSWORD_LENGTH})}" + +# You can make this parameters a required step of the VM instantiation wizard by using the USER_INPUTS feature +# https://docs.opennebula.io/6.8/management_and_operations/vm_management/vm_templates.html?#user-inputs + +############################################################################### +############################################################################### +############################################################################### + +# The following functions will be called by the appliance service manager at +# the different stages of the appliance life cycles. They must exist +# https://github.com/OpenNebula/one-apps/wiki/apps_intro#appliance-life-cycle + +# +# Mandatory Functions +# + +service_install() +{ + mkdir -p "$ONE_SERVICE_SETUP_DIR" + + msg info "Enable EPEL repository" + if ! yum install -y --setopt=skip_missing_names_on_install=False epel-release ; then + msg error "Failed to enable EPEL repository" + exit 1 + fi + + msg info "Install required packages" + if ! yum install -y --setopt=skip_missing_names_on_install=False mariadb mariadb-server expect ; then + msg error "Package(s) installation failed" + exit 1 + fi + + msg info "Delete cache and stored packages" + yum clean all + rm -rf /var/cache/yum + + msg info "INSTALLATION FINISHED" + + return 0 +} + +service_configure() +{ + msg info "Stopping services" + systemctl stop mariadb + + setup_mariadb + + msg info "Credentials and config values are saved in: ${ONE_SERVICE_REPORT}" + + cat > "$ONE_SERVICE_REPORT" < "$MARIADB_CREDENTIALS" < "$MARIADB_CONFIG" < timeout + raise "MySQL service did not become active within #{timeout} seconds" + end + + sleep 1 + end + end + + # Check if the service framework from one-apps reports that the app is ready + it 'check oneapps motd' do + cmd = 'cat /etc/motd' + + execution = @info[:vm].ssh(cmd) + + # you can use pp to help with logging. + # This doesn't verify anything, but helps with inspections + # In this case, we display the motd you get when connecting to the app instance via ssh + pp execution.stdout + + expect(execution.exitstatus).to eq(0) + expect(execution.stdout).to include('All set and ready to serve') + end + + # use mysql CLI to verify root password + it 'can connect as root with defined password' do + pass = APP_CONTEXT_PARAMS[:DB_ROOT_PASSWORD] + cmd = "mysql -u root -p#{pass} -e ''" + + execution = @info[:vm].ssh(cmd) + expect(execution.success?).to be(true) + end + + # use mysql CLI to verify that the database has been created + it 'database exists' do + pass = APP_CONTEXT_PARAMS[:DB_ROOT_PASSWORD] + db = APP_CONTEXT_PARAMS[:DB_NAME] + + cmd = "mysql -u root -p#{pass} -e 'USE #{db};'" + + execution = @info[:vm].ssh(cmd) + expect(execution.success?).to be(true) + end + + # use mysql CLI to verify that the user credentials + it 'can connect as user with defined password' do + user = APP_CONTEXT_PARAMS[:DB_USER] + pass = APP_CONTEXT_PARAMS[:DB_PASSWORD] + + cmd = "mysql -u #{user} -p#{pass} -e ''" + + execution = @info[:vm].ssh(cmd) + expect(execution.success?).to be(true) + end +end + +# Example run +# rspec -f d tutorial_tests.rb +# Appliance Certification +# "onetemplate instantiate base --context SSH_PUBLIC_KEY=\\\"\\$USER[SSH_PUBLIC_KEY]\\\",NETWORK=\"YES\",ONEAPP_DB_NAME=\"dbname\",ONEAPP_DB_USER=\"username\",ONEAPP_DB_PASSWORD=\"upass\",ONEAPP_DB_ROOT_PASSWORD=\"arpass\" --disk service_example" +# mysql is installed +# mysql service is running +# "\n" + +# " ___ _ __ ___\n" + +# " / _ \\ | '_ \\ / _ \\ OpenNebula Service Appliance\n" + +# " | (_) || | | || __/\n" + +# " \\___/ |_| |_| \\___|\n" + +# "\n" + +# " All set and ready to serve 8)\n" + +# "\n" +# check oneapps motd +# can connect as root with defined password +# database exists +# can connect as user with defined password + +# Finished in 1 minute 25.9 seconds (files took 0.22136 seconds to load) +# 6 examples, 0 failures diff --git a/apps-code/community-apps/packer/service_example/81-configure-ssh.sh b/apps-code/community-apps/packer/service_example/81-configure-ssh.sh new file mode 100644 index 0000000..856839c --- /dev/null +++ b/apps-code/community-apps/packer/service_example/81-configure-ssh.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# Configures critical settings for OpenSSH server. + +exec 1>&2 +set -eux -o pipefail + +gawk -i inplace -f- /etc/ssh/sshd_config <<'EOF' +BEGIN { update = "PasswordAuthentication no" } +/^[#\s]*PasswordAuthentication\s/ { $0 = update; found = 1 } +{ print } +ENDFILE { if (!found) print update } +EOF + +gawk -i inplace -f- /etc/ssh/sshd_config <<'EOF' +BEGIN { update = "PermitRootLogin without-password" } +/^[#\s]*PermitRootLogin\s/ { $0 = update; found = 1 } +{ print } +ENDFILE { if (!found) print update } +EOF + +gawk -i inplace -f- /etc/ssh/sshd_config <<'EOF' +BEGIN { update = "UseDNS no" } +/^[#\s]*UseDNS\s/ { $0 = update; found = 1 } +{ print } +ENDFILE { if (!found) print update } +EOF + +sync diff --git a/apps-code/community-apps/packer/service_example/82-configure-context.sh b/apps-code/community-apps/packer/service_example/82-configure-context.sh new file mode 100644 index 0000000..2278ea9 --- /dev/null +++ b/apps-code/community-apps/packer/service_example/82-configure-context.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +# Configure and enable service context. + +exec 1>&2 +set -eux -o pipefail + +mv /etc/one-appliance/net-90-service-appliance /etc/one-context.d/ +mv /etc/one-appliance/net-99-report-ready /etc/one-context.d/ + +chown root:root /etc/one-context.d/* +chmod u=rwx,go=rx /etc/one-context.d/* + +sync diff --git a/apps-code/community-apps/packer/service_example/example.pkr.hcl b/apps-code/community-apps/packer/service_example/example.pkr.hcl new file mode 100644 index 0000000..2833326 --- /dev/null +++ b/apps-code/community-apps/packer/service_example/example.pkr.hcl @@ -0,0 +1,128 @@ +source "null" "null" { communicator = "none" } + +# Prior to setting up the appliance or distro, the context packages need to be generated first +# These will then be installed as part of the setup process +build { + sources = ["source.null.null"] + + provisioner "shell-local" { + inline = [ + "mkdir -p ${var.input_dir}/context", + "${var.input_dir}/gen_context > ${var.input_dir}/context/context.sh", + "mkisofs -o ${var.input_dir}/${var.appliance_name}-context.iso -V CONTEXT -J -R ${var.input_dir}/context", + ] + } +} + +# A Virtual Machine is created with qemu in order to run the setup from the ISO on the CD-ROM +# Here are the details about the VM virtual hardware +source "qemu" "example" { + cpus = 2 + memory = 2048 + accelerator = "kvm" + + iso_url = "export/alma8.qcow2" + iso_checksum = "none" + + headless = var.headless + + disk_image = true + disk_cache = "unsafe" + disk_interface = "virtio" + net_device = "virtio-net" + format = "qcow2" + disk_compression = false + skip_resize_disk = true + + output_directory = var.output_dir + + qemuargs = [["-serial", "stdio"], + ["-cpu", "host"], + ["-cdrom", "${var.input_dir}/${var.appliance_name}-context.iso"], + # MAC addr needs to mach ETH0_MAC from context iso + ["-netdev", "user,id=net0,hostfwd=tcp::{{ .SSHHostPort }}-:22"], + ["-device", "virtio-net-pci,netdev=net0,mac=00:11:22:33:44:55"] + ] + ssh_username = "root" + ssh_password = "opennebula" + ssh_wait_timeout = "900s" + shutdown_command = "poweroff" + vm_name = "${var.appliance_name}" +} + +# Once the VM launches the following logic will be executed inside it to customize what happens inside +# Essentially, a bunch of scripts are pulled from ./appliances and placed inside the Guest OS +# There are shared libraries for ruby and bash. Bash is used in this example +build { + sources = ["source.qemu.example"] + + # revert insecure ssh options done by context start_script + provisioner "shell" { + scripts = ["${var.input_dir}/81-configure-ssh.sh"] + } + + ############################################## + # BEGIN placing script logic inside Guest OS # + ############################################## + + provisioner "shell" { + inline_shebang = "/bin/bash -e" + inline = [ + "install -o 0 -g 0 -m u=rwx,g=rx,o= -d /etc/one-appliance/{,service.d/,lib/}", + "install -o 0 -g 0 -m u=rwx,g=rx,o=rx -d /opt/one-appliance/{,bin/}", + ] + } + + provisioner "file" { + sources = [ + "appliances/scripts/net-90-service-appliance", + "appliances/scripts/net-99-report-ready", + ] + destination = "/etc/one-appliance/" + } + + # Bash libraries at appliances/lib for easier custom implementation in bash logic + provisioner "file" { + sources = [ + "appliances/lib/common.sh", + "appliances/lib/functions.sh", + ] + destination = "/etc/one-appliance/lib/" + } + + # Contains the appliance service management tool + # https://github.com/OpenNebula/one-apps/wiki/apps_intro#appliance-life-cycle + provisioner "file" { + source = "appliances/service.sh" + destination = "/etc/one-appliance/service" + } + # Pull your own custom logic here. Must be called appliance.sh if using bash tools + provisioner "file" { + sources = ["appliances/example/appliance.sh"] + destination = "/etc/one-appliance/service.d/" + } + + provisioner "shell" { + scripts = ["${var.input_dir}/82-configure-context.sh"] + } + + ####################################################################### + # Setup appliance: Execute install step # + # https://github.com/OpenNebula/one-apps/wiki/apps_intro#installation # + ####################################################################### + provisioner "shell" { + inline_shebang = "/bin/bash -e" + inline = ["/etc/one-appliance/service install && sync"] + } + + # Remove machine ID from the VM and get it ready for continuous cloud use + # https://github.com/OpenNebula/one-apps/wiki/tool_dev#appliance-build-process + post-processor "shell-local" { + execute_command = ["bash", "-c", "{{.Vars}} {{.Script}}"] + environment_vars = [ + "OUTPUT_DIR=${var.output_dir}", + "APPLIANCE_NAME=${var.appliance_name}", + ] + scripts = ["packer/postprocess.sh"] + } +} diff --git a/apps-code/community-apps/packer/service_example/gen_context b/apps-code/community-apps/packer/service_example/gen_context new file mode 100755 index 0000000..92ea59e --- /dev/null +++ b/apps-code/community-apps/packer/service_example/gen_context @@ -0,0 +1,33 @@ +#!/bin/bash +set -eux -o pipefail + +SCRIPT=$(cat <<'MAINEND' +gawk -i inplace -f- /etc/ssh/sshd_config <<'EOF' +BEGIN { update = "PasswordAuthentication yes" } +/^[#\s]*PasswordAuthentication\s/ { $0 = update; found = 1 } +{ print } +ENDFILE { if (!found) print update } +EOF + +gawk -i inplace -f- /etc/ssh/sshd_config <<'EOF' +BEGIN { update = "PermitRootLogin yes" } +/^[#\s]*PermitRootLogin\s/ { $0 = update; found = 1 } +{ print } +ENDFILE { if (!found) print update } +EOF + +systemctl reload sshd + +echo "nameserver 1.1.1.1" > /etc/resolv.conf +MAINEND +) + +cat< Date: Thu, 9 Jan 2025 12:48:11 +0100 Subject: [PATCH 03/12] Modify handler image path Signed-off-by: ArnauGabrielAtienza --- .../community-apps/appliances/lib/community/app_handler.rb | 2 +- .../community-apps/appliances/lib/community/app_readiness.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps-code/community-apps/appliances/lib/community/app_handler.rb b/apps-code/community-apps/appliances/lib/community/app_handler.rb index a63f79c..828b96c 100644 --- a/apps-code/community-apps/appliances/lib/community/app_handler.rb +++ b/apps-code/community-apps/appliances/lib/community/app_handler.rb @@ -11,7 +11,7 @@ VM_TEMPLATE = config[:one][:template][:NAME] || 'base' IMAGE_DATASTORE = config[:one][:datastore] || 'default' -APPS_PATH = config[:infra][:apps_path] || '/opt/one-apps/export' +APPS_PATH = ENV['IMG_LOCATION'] DISK_FORMAT = config[:infra][:disk_format] || 'qcow2' APP_IMAGE_NAME = config[:app][:name] diff --git a/apps-code/community-apps/appliances/lib/community/app_readiness.rb b/apps-code/community-apps/appliances/lib/community/app_readiness.rb index 4a3e61a..1e1fe46 100755 --- a/apps-code/community-apps/appliances/lib/community/app_readiness.rb +++ b/apps-code/community-apps/appliances/lib/community/app_readiness.rb @@ -6,11 +6,12 @@ require 'fileutils' if ARGV.empty? - STDERR.puts 'Usage: ./app_readiness.rb ' + STDERR.puts 'Usage: ./app_readiness.rb ' exit(1) end app = ARGV[0] # 'example' +ENV['IMG_LOCATION'] = ARGV[1] tests_list_path = "../../#{app}/tests.yaml" tests_path = "../../#{app}/tests" From 676b7ecce3e0bea80fec44bc7866250ba34ad09c Mon Sep 17 00:00:00 2001 From: ArnauGabrielAtienza Date: Thu, 9 Jan 2025 12:50:32 +0100 Subject: [PATCH 04/12] Modify handler iamge path Signed-off-by: ArnauGabrielAtienza --- .../community-apps/appliances/lib/community/app_handler.rb | 2 +- .../community-apps/appliances/lib/community/app_readiness.rb | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps-code/community-apps/appliances/lib/community/app_handler.rb b/apps-code/community-apps/appliances/lib/community/app_handler.rb index 828b96c..5ecc122 100644 --- a/apps-code/community-apps/appliances/lib/community/app_handler.rb +++ b/apps-code/community-apps/appliances/lib/community/app_handler.rb @@ -11,7 +11,7 @@ VM_TEMPLATE = config[:one][:template][:NAME] || 'base' IMAGE_DATASTORE = config[:one][:datastore] || 'default' -APPS_PATH = ENV['IMG_LOCATION'] +APPS_PATH = ENV['IMAGES_URL'] DISK_FORMAT = config[:infra][:disk_format] || 'qcow2' APP_IMAGE_NAME = config[:app][:name] diff --git a/apps-code/community-apps/appliances/lib/community/app_readiness.rb b/apps-code/community-apps/appliances/lib/community/app_readiness.rb index 1e1fe46..1268b97 100755 --- a/apps-code/community-apps/appliances/lib/community/app_readiness.rb +++ b/apps-code/community-apps/appliances/lib/community/app_readiness.rb @@ -11,7 +11,6 @@ end app = ARGV[0] # 'example' -ENV['IMG_LOCATION'] = ARGV[1] tests_list_path = "../../#{app}/tests.yaml" tests_path = "../../#{app}/tests" From a9e4fe75282a1c02e6c8f4ce06fa2f4ee7df978b Mon Sep 17 00:00:00 2001 From: ArnauGabrielAtienza Date: Thu, 9 Jan 2025 13:09:15 +0100 Subject: [PATCH 05/12] Debug message in app_handler Signed-off-by: ArnauGabrielAtienza --- .../community-apps/appliances/lib/community/app_handler.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps-code/community-apps/appliances/lib/community/app_handler.rb b/apps-code/community-apps/appliances/lib/community/app_handler.rb index 5ecc122..d246882 100644 --- a/apps-code/community-apps/appliances/lib/community/app_handler.rb +++ b/apps-code/community-apps/appliances/lib/community/app_handler.rb @@ -27,6 +27,8 @@ path = "#{APPS_PATH}/#{APP_IMAGE_NAME}.#{DISK_FORMAT}" + puts "Creating image #{APP_IMAGE_NAME} from #{path}" + CLIImage.create(APP_IMAGE_NAME, IMAGE_DATASTORE, "--path #{path}") end From d05b3610252640e70787260a0ff85842fab1de41 Mon Sep 17 00:00:00 2001 From: ArnauGabrielAtienza Date: Thu, 9 Jan 2025 13:34:37 +0100 Subject: [PATCH 06/12] Fix path string Signed-off-by: ArnauGabrielAtienza --- .../community-apps/appliances/lib/community/app_handler.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps-code/community-apps/appliances/lib/community/app_handler.rb b/apps-code/community-apps/appliances/lib/community/app_handler.rb index d246882..c63f3df 100644 --- a/apps-code/community-apps/appliances/lib/community/app_handler.rb +++ b/apps-code/community-apps/appliances/lib/community/app_handler.rb @@ -25,7 +25,7 @@ if !CLIImage.list('-l NAME').include?(APP_IMAGE_NAME) - path = "#{APPS_PATH}/#{APP_IMAGE_NAME}.#{DISK_FORMAT}" + path = "#{APPS_PATH}#{APP_IMAGE_NAME}.#{DISK_FORMAT}" puts "Creating image #{APP_IMAGE_NAME} from #{path}" From b3b2f8338e172858e74d08fc6e01f577d41ad796 Mon Sep 17 00:00:00 2001 From: ArnauGabrielAtienza Date: Fri, 10 Jan 2025 11:50:31 +0100 Subject: [PATCH 07/12] Add defaults merger Signed-off-by: ArnauGabrielAtienza --- .../lib/community/defaults_merger.rb | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 apps-code/community-apps/appliances/lib/community/defaults_merger.rb diff --git a/apps-code/community-apps/appliances/lib/community/defaults_merger.rb b/apps-code/community-apps/appliances/lib/community/defaults_merger.rb new file mode 100644 index 0000000..1a00c39 --- /dev/null +++ b/apps-code/community-apps/appliances/lib/community/defaults_merger.rb @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby +require 'yaml' +require 'optparse' + +# Parse command-line arguments +options = {} +OptionParser.new do |opts| + opts.banner = "Usage: ruby merge_yaml.rb [options]" + + opts.on("-s", "--source SOURCE", "Source YAML file") do |source| + options[:source] = source + end + + opts.on("-d", "--destination DESTINATION", "Destination YAML file") do |destination| + options[:destination] = destination + end +end.parse! + +# Ensure both source and destination files are provided +if options[:source].nil? || options[:destination].nil? + puts "Both source and destination files must be provided!" + exit 1 +end + +source_file = options[:source] +destination_file = options[:destination] + +# Load source and destination YAML files +source_data = YAML.load_file(source_file) +destination_data = YAML.load_file(destination_file) + +# Merge `:tests` sections +source_tests = source_data[:tests] || {} +destination_tests = destination_data[:tests] || {} + +# Add source tests to destination tests +merged_tests = destination_tests.merge(source_tests) + +# Update the destination data with the merged tests +destination_data[:tests] = merged_tests + +# Save the updated destination YAML file +File.open(destination_file, 'w') do |file| + file.write(destination_data.to_yaml) +end + +puts "Tests merged successfully from '#{source_file}' to '#{destination_file}'!" \ No newline at end of file From c906093e0698be031e1044c84a3dcd10fd374cd6 Mon Sep 17 00:00:00 2001 From: ArnauGabrielAtienza Date: Fri, 10 Jan 2025 12:33:47 +0100 Subject: [PATCH 08/12] Change defaults name Signed-off-by: ArnauGabrielAtienza --- .../community-apps/appliances/lib/community/app_handler.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps-code/community-apps/appliances/lib/community/app_handler.rb b/apps-code/community-apps/appliances/lib/community/app_handler.rb index c63f3df..f04997d 100644 --- a/apps-code/community-apps/appliances/lib/community/app_handler.rb +++ b/apps-code/community-apps/appliances/lib/community/app_handler.rb @@ -78,7 +78,7 @@ def generate_context(metadata) context_input = <<~EOT --- :tests: - '#{metadata[:app][:os][:base]}': + '##{name}': :image_name: #{name}.#{metadata[:infra][:disk_format]} :type: #{metadata[:app][:os][:type]} :microenvs: ['context-#{metadata[:app][:hypervisor].downcase}'] From b0f091c65df05d4807f2eda8b69858538037f046 Mon Sep 17 00:00:00 2001 From: ArnauGabrielAtienza Date: Tue, 14 Jan 2025 09:47:53 +0100 Subject: [PATCH 09/12] Fix typo Signed-off-by: ArnauGabrielAtienza --- .../community-apps/appliances/lib/community/app_handler.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps-code/community-apps/appliances/lib/community/app_handler.rb b/apps-code/community-apps/appliances/lib/community/app_handler.rb index f04997d..f878f4e 100644 --- a/apps-code/community-apps/appliances/lib/community/app_handler.rb +++ b/apps-code/community-apps/appliances/lib/community/app_handler.rb @@ -78,7 +78,7 @@ def generate_context(metadata) context_input = <<~EOT --- :tests: - '##{name}': + '#{name}': :image_name: #{name}.#{metadata[:infra][:disk_format]} :type: #{metadata[:app][:os][:type]} :microenvs: ['context-#{metadata[:app][:hypervisor].downcase}'] From 4b460049c118c8dd7d77021ede35f94d72430d9a Mon Sep 17 00:00:00 2001 From: ArnauGabrielAtienza Date: Tue, 14 Jan 2025 12:23:49 +0100 Subject: [PATCH 10/12] Add Makefile Signed-off-by: ArnauGabrielAtienza --- .github/workflows/state_machine.yml | 19 +++++++++++++++++-- apps-code/community-apps/Makefile | 4 ++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/state_machine.yml b/.github/workflows/state_machine.yml index e1e8903..271d4e8 100644 --- a/.github/workflows/state_machine.yml +++ b/.github/workflows/state_machine.yml @@ -21,6 +21,11 @@ jobs: if: startsWith(github.event.label.name, 'do_tests') runs-on: ubuntu-latest steps: + - name: Checkout PR code + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.ref }} + - name: Configure SSH env: SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} @@ -30,6 +35,11 @@ jobs: chmod 600 ~/.ssh/id_rsa echo -e "Host *\n\tStrictHostKeyChecking no\n" > ~/.ssh/config + - name: Install yq + run: | + sudo apt-get update + sudo apt-get install yq + - name: Trigger Jenkins env: VPN_SERVER: ${{ secrets.VPN_SERVER }} @@ -38,9 +48,14 @@ jobs: JENKINS_USER: ${{ secrets.JENKINS_USER }} JENKINS_TOKEN: ${{ secrets.JENKINS_TOKEN }} run: | + METADATA_PATH="apps-code/community-apps/appliances/${{ github.event.pull_request.title }}/metadata.yaml" + DISTRO=$(yq '.[":app"][":name"]' $METADATA_PATH) + REQUIREMENTS=$(yq '.[":app"][":os"][":base"]' $METADATA_PATH) ssh -i ~/.ssh/id_rsa gitact@$VPN_SERVER -p 2222 "curl -X POST $JENKINS_URL/job/one-community-distro/buildWithParameters?token=$ONE_DISTRO_TOKEN \ --user $JENKINS_USER:$JENKINS_TOKEN \ - --data DISTROS=${{ github.event.pull_request.title }} \ + --data DISTROS=$DISTRO \ + --data REQUIREMENTS=$REQUIREMENTS \ + --data RUN_CONTEXT=true \ --data PR_NUMBER=${{ github.event.pull_request.number }}" - name: Update labels @@ -48,4 +63,4 @@ jobs: gh pr edit ${{ github.event.pull_request.number }} --repo ${{ github.repository_owner }}/${{ github.event.repository.name }} --add-label "IN_TESTING" gh pr edit ${{ github.event.pull_request.number }} --repo ${{ github.repository_owner }}/${{ github.event.repository.name }} --remove-label "do_tests,CHANGES_REQUESTED" env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} \ No newline at end of file diff --git a/apps-code/community-apps/Makefile b/apps-code/community-apps/Makefile index a818ae3..9dc5cf5 100644 --- a/apps-code/community-apps/Makefile +++ b/apps-code/community-apps/Makefile @@ -14,6 +14,10 @@ $(SERVICES): %: packer-% packer-%: ${DIR_EXPORT}/%.qcow2 @${INFO} "Packer ${*} done" +# Define if your appliance depends on a distro. This example builds on top of alma8 packer build +packer-service_example: packer-alma8 ${DIR_EXPORT}/service_example.qcow2 + @${INFO} "Packer service_example done" + # run packer build for given distro or service ${DIR_EXPORT}/%.qcow2: $(eval DISTRO_NAME := $(shell echo ${*} | sed 's/[0-9].*//')) From 2b8aff1a8084ea6c1e83cda3dbbbc2c4b325a892 Mon Sep 17 00:00:00 2001 From: ArnauGabrielAtienza Date: Tue, 14 Jan 2025 13:09:15 +0100 Subject: [PATCH 11/12] Add to makefile list Signed-off-by: ArnauGabrielAtienza --- apps-code/community-apps/Makefile.config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps-code/community-apps/Makefile.config b/apps-code/community-apps/Makefile.config index 1fd91ad..f9e8a2a 100644 --- a/apps-code/community-apps/Makefile.config +++ b/apps-code/community-apps/Makefile.config @@ -7,7 +7,7 @@ VERBOSE := 1 PACKER_LOG := 0 PACKER_HEADLESS := true -SERVICES := service_Lithops service_UERANSIM capone131 +SERVICES := service_Lithops service_UERANSIM capone131 service_example .DEFAULT_GOAL := help From 1d468b6206f4093a8e97e28279957e48a3366ee7 Mon Sep 17 00:00:00 2001 From: ArnauGabrielAtienza Date: Tue, 14 Jan 2025 14:52:15 +0100 Subject: [PATCH 12/12] Update Example Signed-off-by: ArnauGabrielAtienza --- apps-code/community-apps/Makefile | 4 ---- .../community-apps/packer/service_example/example.pkr.hcl | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/apps-code/community-apps/Makefile b/apps-code/community-apps/Makefile index 9dc5cf5..a818ae3 100644 --- a/apps-code/community-apps/Makefile +++ b/apps-code/community-apps/Makefile @@ -14,10 +14,6 @@ $(SERVICES): %: packer-% packer-%: ${DIR_EXPORT}/%.qcow2 @${INFO} "Packer ${*} done" -# Define if your appliance depends on a distro. This example builds on top of alma8 packer build -packer-service_example: packer-alma8 ${DIR_EXPORT}/service_example.qcow2 - @${INFO} "Packer service_example done" - # run packer build for given distro or service ${DIR_EXPORT}/%.qcow2: $(eval DISTRO_NAME := $(shell echo ${*} | sed 's/[0-9].*//')) diff --git a/apps-code/community-apps/packer/service_example/example.pkr.hcl b/apps-code/community-apps/packer/service_example/example.pkr.hcl index 2833326..6acd55f 100644 --- a/apps-code/community-apps/packer/service_example/example.pkr.hcl +++ b/apps-code/community-apps/packer/service_example/example.pkr.hcl @@ -75,8 +75,8 @@ build { provisioner "file" { sources = [ - "appliances/scripts/net-90-service-appliance", - "appliances/scripts/net-99-report-ready", + "../one-apps/appliances/scripts/net-90-service-appliance", + "../one-apps/appliances/scripts/net-99-report-ready", ] destination = "/etc/one-appliance/" } @@ -93,7 +93,7 @@ build { # Contains the appliance service management tool # https://github.com/OpenNebula/one-apps/wiki/apps_intro#appliance-life-cycle provisioner "file" { - source = "appliances/service.sh" + source = "../one-apps/appliances/service.sh" destination = "/etc/one-appliance/service" } # Pull your own custom logic here. Must be called appliance.sh if using bash tools