diff --git a/context-linux/src/etc/one-context.d/net-95-cloudinit-start b/context-linux/src/etc/one-context.d/net-95-cloudinit-start new file mode 100755 index 00000000..b3361aaa --- /dev/null +++ b/context-linux/src/etc/one-context.d/net-95-cloudinit-start @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +# -------------------------------------------------------------------------- # +# Copyright 2002-2024, 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. # +#--------------------------------------------------------------------------- # + +set -eo pipefail + +export USER_DATA="${USER_DATA:-${USERDATA}}" +if [ -z "$USER_DATA" ]; then + echo "No USER_DATA env variable found. Skipping execution..." + exit 0 +fi + +CLOUDINIT_BASE_DIR="/var/lib/one-context/cloudinit" +CLOUDINIT_ENV_FILE="${CLOUDINIT_BASE_DIR}/cloudinit_env.sh" +CLOUDINIT_LOCK_FILE="${CLOUDINIT_BASE_DIR}/cloudinit-boot-finished" +CLOUDINIT_RUNCMD_TMP_DIR="${CLOUDINIT_BASE_DIR}/tmp" +CLOUDINIT_RUNCMD_TMP_SCRIPT="${CLOUDINIT_RUNCMD_TMP_DIR}/runcmd_script.sh" + +bootstrap_cloudinit_env() +{ + install -m "u=rwx,go=" -d "${CLOUDINIT_BASE_DIR}" + { + echo "export CLOUDINIT_LOCK_FILE=${CLOUDINIT_LOCK_FILE}" + echo "export CLOUDINIT_BASE_DIR=${CLOUDINIT_BASE_DIR}" + echo "export CLOUDINIT_RUNCMD_TMP_DIR=${CLOUDINIT_RUNCMD_TMP_DIR}" + echo "export CLOUDINIT_RUNCMD_TMP_SCRIPT=${CLOUDINIT_RUNCMD_TMP_SCRIPT}" + } >> "${CLOUDINIT_ENV_FILE}" + +} + +if [ -e "${CLOUDINIT_LOCK_FILE}" ]; then + echo "Lock file exists in ${CLOUDINIT_LOCK_FILE}. Skipping execution..." + exit 0 +fi +bootstrap_cloudinit_env + +# creates tmp dir for cloudinit runcmd script +install -m "u=rwx,go=" -d "${CLOUDINIT_RUNCMD_TMP_DIR}" + +USER_DATA_ENCODING="${USER_DATA_ENCODING:-${USERDATA_ENCODING}}" + +if [ "${USER_DATA_ENCODING}" = "base64" ]; then + if ! USER_DATA="$(echo "${USER_DATA}" | base64 -d 2>/dev/null)"; then + echo "Error: Failed base64 decoding of userdata" >&2 + exit 1 + fi +fi + +# shellcheck source=/dev/null +. "${CLOUDINIT_ENV_FILE}" + +one_cloudinit.rb +EXIT_CODE=$? + +if [ "${EXIT_CODE}" -ne 0 ]; then + echo "one_cloudinit execution failed. Exit code: ${EXIT_CODE}" + exit 1 +fi +echo "one_cloudinit execution finished" diff --git a/context-linux/src/etc/one-context.d/net-96-cloudinit-finish b/context-linux/src/etc/one-context.d/net-96-cloudinit-finish new file mode 100755 index 00000000..020c7f6e --- /dev/null +++ b/context-linux/src/etc/one-context.d/net-96-cloudinit-finish @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +# -------------------------------------------------------------------------- # +# Copyright 2002-2024, 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. # +#--------------------------------------------------------------------------- # + +set -eo pipefail + +# avoid executing this script when no $USER_DATA or $USERDATA is granted (avoids locking multi-stage builds) +export USER_DATA="${USER_DATA:-${USERDATA}}" +if [ -z "$USER_DATA" ]; then + echo "No USER_DATA env variable found. Skipping execution..." + exit 0 +fi + +CLOUDINIT_BASE_DIR="/var/lib/one-context/cloudinit" +CLOUDINIT_ENV_FILE="${CLOUDINIT_BASE_DIR}/cloudinit_env.sh" + +lock_and_cleanup() +{ + # write cloud-init boot finished file + date +"%Y-%m-%d %H:%M:%S" > "${CLOUDINIT_LOCK_FILE}" + rm -rf "${CLOUDINIT_RUNCMD_TMP_DIR}" +} + +# Source cloudinit env vars +# shellcheck source=/dev/null +. "${CLOUDINIT_ENV_FILE}" + +if [ -e "${CLOUDINIT_LOCK_FILE}" ]; then + echo "Lock file exists in ${CLOUDINIT_LOCK_FILE}. Skipping execution..." + exit 0 +fi + +trap lock_and_cleanup EXIT + +# Execute cloudinit scripts +if [ -e "${CLOUDINIT_RUNCMD_TMP_SCRIPT}" ]; then + + chmod u+x "${CLOUDINIT_RUNCMD_TMP_SCRIPT}" + + echo "Executing ${CLOUDINIT_LOCK_FILE}..." + set +e + $SHELL -ex "${CLOUDINIT_RUNCMD_TMP_SCRIPT}" + EXIT_CODE=$? + set -e + if [ "${EXIT_CODE}" -ne 0 ]; then + echo "runcmd script execution failed. Exit code: ${EXIT_CODE}" + exit 1 + fi + echo "runcmd script execution finished" + +else + echo "No runcmd script found in: ${CLOUDINIT_RUNCMD_TMP_SCRIPT}. Skipping execution..." +fi + diff --git a/context-linux/src/usr/bin/cloudinit_cc_run_cmd.rb b/context-linux/src/usr/bin/cloudinit_cc_run_cmd.rb new file mode 100755 index 00000000..bcd82f9a --- /dev/null +++ b/context-linux/src/usr/bin/cloudinit_cc_run_cmd.rb @@ -0,0 +1,87 @@ +#!/usr/bin/env ruby + +# -------------------------------------------------------------------------- # +# Copyright 2002-2024, 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. # +#--------------------------------------------------------------------------- # + +require 'fileutils' + +module CloudInit + + ## + # RunCmd class implements the runcmd cloud-config directive. + ## + class RunCmd + + attr_accessor :cmd_list + + def initialize(cmd_list) + raise 'RunCmd must be instantiated with a command list as an argument' \ + unless cmd_list.is_a?(Array) + + @cmd_list = cmd_list + end + + def exec + if @cmd_list.empty? + CloudInit::Logger.debug('[runCmd] empty cmdlist, ignoring...') + return + end + CloudInit::Logger.debug("[runCmd] processing commands'") + + runcmd_script_path = ENV['CLOUDINIT_RUNCMD_TMP_SCRIPT'] + if !runcmd_script_path + raise 'mandatory CLOUDINIT_RUNCMD_TMP_SCRIPT env var not found!' + end + + begin + file_content = create_shell_file_content + rescue StandardError => e + raise "could not generate runcmd script file content: #{e.message}" + end + + File.open(runcmd_script_path, 'w', 0o700) do |file| + file.write(file_content) + end + + CloudInit::Logger.debug( + "[runCmd] runcmd script successfully created in '#{runcmd_script_path}'" + ) + end + + def create_shell_file_content + content = "#!/bin/sh\n" + @cmd_list.each do |cmd| + if cmd.is_a?(Array) + escaped = [] + cmd.each do |token| + # Ensure that each element of the command in the + # array is properly shell-protected with single quotes + modified_string = token.gsub("'") {|x| "'\\#{x}'" } + escaped << "\'#{modified_string}\'" + end + content << "#{escaped.join(' ')}\n" + elsif cmd.is_a?(String) + content << "#{cmd}\n" + else + raise 'incompatible command specification, must be array or string' + end + end + return content + end + + end + +end diff --git a/context-linux/src/usr/bin/cloudinit_cc_write_files.rb b/context-linux/src/usr/bin/cloudinit_cc_write_files.rb new file mode 100755 index 00000000..75ce5054 --- /dev/null +++ b/context-linux/src/usr/bin/cloudinit_cc_write_files.rb @@ -0,0 +1,180 @@ +#!/usr/bin/env ruby + +# -------------------------------------------------------------------------- # +# Copyright 2002-2024, 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. # +#--------------------------------------------------------------------------- # + +require 'etc' +require 'base64' +require 'zlib' +require 'fileutils' +require 'stringio' +require 'open-uri' + +module CloudInit + + DEFAULT_PERMS = '0644' + DEFAULT_OWNER = 'root:root' + TEXT_PLAIN_ENC = 'text/plain' + DEFAULT_DEFER = false + DEFAULT_APPEND = false + + ## + # WriteFile class implements the write_file cloud-config directive. + ## + class WriteFile + + attr_accessor :path, :content, :source, :owner, :permissions, :encoding, :append, :defer + + def initialize(path:, content: '', source: [], owner: DEFAULT_OWNER, + permissions: DEFAULT_PERMS, encoding: TEXT_PLAIN_ENC, + append: DEFAULT_APPEND, defer: DEFAULT_DEFER) + @path = path + @content = content + @source = source + @owner = owner + @permissions = permissions + @encoding = encoding + @append = append + @defer = defer + end + + def exec + begin + CloudInit::Logger.info( + "[writeFile] writing file [#{@permissions} #{@owner} #{@path}]" + ) + + uid, gid = uid_and_guid_by_owner + FileUtils.mkdir_p(File.dirname(@path)) + File.open(File.absolute_path(@path), @append ? 'ab' : 'wb') do |file| + file.write(read_url_or_decode) + file.chmod(decode_perms) + file.chown(uid, gid) + end + rescue Errno::EACCES + CloudInit::Logger.error( + "[writeFile] Insufficient permissions [#{@permissions} #{@owner} #{@path}]" + ) + rescue Errno::ENOENT + CloudInit::Logger.error( + "[writeFile] Parent directory missing [#{@permissions} #{@owner} #{@path}]" + ) + rescue StandardError => e + CloudInit::Logger.error( + "[writeFile] Unexpected error: #{e.message}\n#{e.backtrace.join("\n")}" + ) + end + end + + def self.from_map(data_map) + unless data_map.is_a?(Hash) + raise 'WriteFile.from_map must be called with a Hash as an argument' + end + + WriteFile.new(**data_map) + end + + def read_url_or_decode + url = @source.is_a?(Hash) ? @source[:uri] : nil + use_url = !url.nil? + + return '' if @content.nil? && !use_url + + result = nil + if use_url + begin + headers = @source[:headers]&.transform_keys(&:to_s) || {} + result = URI.parse(url).open(headers).read + rescue StandardError => e + CloudInit::Logger.error( + "Failed to retrieve contents from source '#{url}'; \n + falling back to content: #{e.message}" + ) + use_url = false + end + end + + if @content && !use_url + extractions = canonicalize_extraction + result = extract_contents(extractions) + end + + result + end + + def decode_perms + if @permissions.is_a?(String) && @permissions.match?(/\A0[0-7]{1,3}\z/) + @permissions.to_i(8) + else + CloudInit::Logger.warn( + 'Undecodable permissions, returning default' + ) + DEFAULT_PERMS.to_i(8) + end + end + + def uid_and_guid_by_owner + user, group = @owner.split(':') + + begin + return Etc.getpwnam(user).uid, Etc.getgrnam(group).gid + rescue ArgumentError + CloudInit::Logger.error( + "[writeFile] Owner does not exist [#{@permissions} #{@owner} #{@path}]" + ) + user, group = DEFAULT_OWNER.split(':') + return Etc.getpwnam(user).uid, Etc.getgrnam(group).gid + end + end + + def canonicalize_extraction + encoding_type = @encoding.downcase.strip + + case encoding_type + when 'gz', 'gzip' + ['application/x-gzip'] + when 'gz+base64', 'gzip+base64', 'gz+b64', 'gzip+b64' + ['application/base64', 'application/x-gzip'] + when 'b64', 'base64' + ['application/base64'] + when TEXT_PLAIN_ENC + [TEXT_PLAIN_ENC] + else + CloudInit::Logger.warn( + "Unknown encoding type #{encoding_type}, assuming #{TEXT_PLAIN_ENC}" + ) + [TEXT_PLAIN_ENC] + end + end + + def extract_contents(extraction_types) + result = @content + extraction_types.each do |t| + case t + when 'application/x-gzip' + result = Zlib::GzipReader.new(StringIO.new(result)).read + when 'application/base64' + result = Base64.decode64(result) + when TEXT_PLAIN_ENC + # No transformation needed + end + end + result + end + + end + +end diff --git a/context-linux/src/usr/bin/one_cloudinit.rb b/context-linux/src/usr/bin/one_cloudinit.rb new file mode 100755 index 00000000..f5df838f --- /dev/null +++ b/context-linux/src/usr/bin/one_cloudinit.rb @@ -0,0 +1,150 @@ +#!/usr/bin/env ruby + +# -------------------------------------------------------------------------- # +# Copyright 2002-2024, 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. # +#--------------------------------------------------------------------------- # + +require 'psych' +require 'logger' +require_relative 'cloudinit_cc_run_cmd' +require_relative 'cloudinit_cc_write_files' + +## +# The CloudInit module implements cloud-init features for OpenNebula +# contextualization. +## +module CloudInit + + Logger = Logger.new( + STDOUT, + :level => Logger::INFO, + :formatter => proc {|severity, datetime, _progname, msg| + date_format = datetime.strftime('%Y-%m-%d %H:%M:%S') + "[#{date_format} - #{severity}]: #{msg}\n" + } + ) + + def self.print_exception(exception, prefix = 'Exception') + Logger.error("#{prefix}: #{exception.message}\n#{exception.backtrace.join("\n")}") + end + + def self.load_cloud_config_from_user_data + if (user_data = ENV['USER_DATA']).nil? + Logger.info('No USER_DATA found.') + return + end + CloudConfig.from_yaml(user_data) + end + + ## + # The CloudConfig module contains the functionality for parsing and executing + # cloud-config YAML files. + ## + class CloudConfig + + attr_accessor :write_files, :runcmd, :deferred_write_files + + def initialize(write_files = [], runcmd = [], deferred_write_files = []) + @write_files = write_files + @runcmd = runcmd + @deferred_write_files = deferred_write_files + end + + def self.from_yaml(yaml_string) + begin + parsed_cloud_config = Psych.safe_load(yaml_string, :symbolize_names => true) + rescue Psych::SyntaxError => e + raise "YAML parsing failed: #{e.message}" + end + + write_files = CloudConfigList.new( + parsed_cloud_config[:write_files].reject {|file| file[:defer] }, + WriteFile.method(:from_map) + ) if parsed_cloud_config.key?(:write_files) + + runcmd = RunCmd.new(parsed_cloud_config[:runcmd]) if parsed_cloud_config.key?(:runcmd) + + deferred_write_files = CloudConfigList.new( + parsed_cloud_config[:write_files].select {|file| file[:defer] }, + WriteFile.method(:from_map) + ) if parsed_cloud_config.key?(:write_files) + + return new(write_files, runcmd, deferred_write_files) + end + + def exec + # TODO: Define directives execution order + instance_variables.each do |var| + cloudconfig_directive = instance_variable_get(var) + if !cloudconfig_directive.respond_to?(:exec) + # TODO: Raise a not implemented exception or just ignore? + next + end + + cloudconfig_directive.exec + end + end + + ## + # CloudConfigList class manages generic cloud-config directives lists + ## + class CloudConfigList + + attr_accessor :cloud_config_list + + def initialize(data_array, mapping_method) + raise 'CloudConfigList should be initialized with an Array' \ + unless data_array.is_a?(Array) + + @cloud_config_list = data_array.map do |element| + mapping_method.call(element) + end + end + + def exec + @cloud_config_list.each do |element| + if !element.respond_to?(:exec) + # TODO: Raise a not implemented exception or just ignore? + next + end + + element.exec + end + end + + end + + end + +end + +# script start +begin + cloud_config = CloudInit.load_cloud_config_from_user_data +rescue StandardError => e + CloudInit.print_exception(e, 'could not parse USER_DATA') + exit 1 +end + +if cloud_config.nil? + exit 0 +end + +begin + cloud_config.exec +rescue StandardError => e + CloudInit.print_exception(e, 'error executing cloud-config') + exit 1 +end