#!/bin/bash
#
# cpictl - Configure the Control-Program-Information (CPI) settings
#
# This is an internal helper script that is used by the "cpi.service"
# systemd unit and the "90-cpi.rules" udev rule.
#
# The bash shell is really needed. Other shells have different ideas of how
# bitwise operators work.
#
# Copyright 2017 IBM Corp.
#
# s390-tools is free software; you can redistribute it and/or modify
# it under the terms of the MIT license. See LICENSE for details.
#

readonly CPI_LOCK="/var/lock/cpictl.lock"

readonly PRG="${0##*/}"

readonly SYSTEM_LEVEL_PATH="/sys/firmware/cpi/system_level"
readonly SYSTEM_TYPE_PATH="/sys/firmware/cpi/system_type"
readonly SYSTEM_NAME_PATH="/sys/firmware/cpi/system_name"
readonly SYSPLEX_NAME_PATH="/sys/firmware/cpi/sysplex_name"
readonly CPI_SET="/sys/firmware/cpi/set"

# Location of os-release file - can be specified externally for testing purpose
readonly OS_RELEASE=${CPI_OS_RELEASE:-"/etc/os-release"}

declare LEVEL
declare TYPE
declare NAME
declare SYSPLEX

declare -i DRYRUN=0

# Exit codes
readonly EXIT_SUCCESS=0
readonly EXIT_FAILURE=1
readonly EXIT_ARG_TOO_LONG=3
readonly EXIT_INVALID_CHARS=4

# Distro-IDs as supported by SE/HMC firmware
readonly DISTRO_GENERIC=0
readonly DISTRO_RHEL=1
readonly DISTRO_SLES=2
readonly DISTRO_UBUNTU=3
readonly DISTRO_FEDORA=4
readonly DISTRO_OPENSUSE=5
readonly DISTRO_DEBIAN=6
readonly DISTRO_RHCOS=7

print_help_and_exit()
{
	cat <<EndHelp
Usage: $PRG [OPTIONS]

Configure the Control-Program-Information (CPI) settings.

  -b, --set-bit BIT      Set and commit the bit BIT in the flags
  -e, --environment      Set and commit the system type, level, name and
                         sysplex name with values taken from environment
                         variables
  -h, --help             Print this help, then exit
  -L, --level LEVEL      Set and commit OS level to LEVEL. Format: 0x<level> or
                         [[[flags:]distro_id:distro_version:]kernel_version]
  -N, --name SYSTEM      Set and commit the system name to SYSTEM
  -S, --sysplex SYSPLEX  Set and commit the sysplex name to SYSPLEX
  -T, --type TYPE        Set and commit OS type to TYPE
  -v, --version          Print version information, then exit
  --commit               Ignore all other options and commit any uncommitted
                         values
  --dry-run              Do not actually set or commit anything, but show what
                         would be done
  --show                 Ignore all other options and show current (possibly
                         uncommitted) values

Environment variables used for the --defaults option:
  CPI_SYSTEM_TYPE, CPI_SYSTEM_LEVEL, CPI_SYSTEM_NAME, CPI_SYSPLEX_NAME

Available bits for the --set-bit option:
  kvm: Indicate that system is a KVM host

The maximum length for system type, system name, and sysplex name is 8
characters. The allowed characters are: [A-Z0-9 @#$]

This tool should be called as root.
EndHelp
	exit $EXIT_SUCCESS
}

print_version_and_exit()
{
	cat <<EndVersion
$PRG: Configure the CPI settings version 2.20.0-build-20241018
Copyright IBM Corp. 2017
EndVersion
	exit $EXIT_SUCCESS
}

cpi_show()
{
	cat <<-EndShow
	System type:  $(cat "$SYSTEM_TYPE_PATH")
	System level: $(cat "$SYSTEM_LEVEL_PATH")
	System name:  $(cat "$SYSTEM_NAME_PATH")
	Sysplex name: $(cat "$SYSPLEX_NAME_PATH")
	EndShow
}

print_parse_error_and_exit()
{
	echo "Try '$PRG --help' for more information." >&2
	exit $EXIT_FAILURE
}

fail_with()
{
	echo "$1" >&2
	echo "Try '$PRG --help' for more information." >&2
	exit ${2:-$EXIT_FAILURE}
}

cpi_commit()
{
	echo 1 > "$CPI_SET" 2> /dev/null
}

do_length_check()
{
	[ ${#1} -gt 8 ] &&
		fail_with "$PRG: Specified $2 too long. The maximum length is 8 characters." $EXIT_ARG_TOO_LONG
}

do_character_check()
{
	echo "$1" | grep -q -E '^[a-zA-Z0-9@#$ ]*$' ||
		fail_with "$PRG: Invalid characters in $2. Valid characters are: A-Z0-9 @#$" $EXIT_INVALID_CHARS
}

cpi_set_bit()
{
	LEVEL=$(printf '0x%x' $((LEVEL | (1 << (63 - $1)) )) )
}

#
# split_version - Split generic version string into array of sub-versions
#
# @version: Version string
# @delim: Characters that delimit sub-versions in version string
# @num: Number of sub-versions
#
# Print @num sub-versions of @version where each sub-version is delimited by
# any of the characters in @delim. Print 0 in place of non-decimal or missing
# sub-versions.
#
# Examples:
# version=10   delim=.  num=3 => 10 0 0
# version=4.8  delim=.  num=2 => 4 8
#
split_version()
{
	local version="$1" delim="$2" num="$3"
	local list i subver

	IFS="$delim" read -r -a list <<< "$version"

	for (( i=0; i<num; i++ )) ; do
		subver="${list[i]:-0}"
		# Handle non-number sub-versions
		[[ "$subver" =~ ^[0-9]+$ ]] || subver=0
		# Force decimal interpretation in case of leading zeroes
		subver=$(( 10#$subver ))
		printf "%s " "$subver"
	done
}

#
# split_kver - Split Linux kernel version string into array of sub-versions
#
# @version: Linux kernel version string
# @num: Number of sub-versions
#
# Print @num sub-versions of the specified kernel @version. Print 0 in place
# of non-decimal or missing sub-versions.
#
# Examples:
# version=2.4-13                      num=4 => 2 4 0 13
# version=3.0.93_3.0.101-0.8.2_0.8.1  num=6 => 3 0 93 3 0 101
# version=4.12.14-lp150.11.4          num=5 => 4 12 14 0 11
#
split_kver()
{
	local version="$1" num="$2"
	local main extra

	# Separate extra version to handle short main version (e.g. 2.4-13)
	IFS="-_" read -r main extra <<< "$version"

	split_version "$main" "." $(( num > 3 ? 3 : num ))
	[[ "$num" -gt 3 ]] && split_version "$extra" ".-_" $(( num - 3 ))
}

#
# bytes_to_word - Convert byte array to hexadecimal word
#
# @bytes: List of numbers representing byte values
#
# Print a big-endian hexadecimal representation of the word that results from
# combining the specified byte values.
#
bytes_to_word()
{
	printf "0x"
	printf "%02x" "$@"
}

#
# get_system_level - Print system level word for specified distribution version
#
# @distro: Distro ID (ID value from /etc/os-release)
# @ver_str: Distro version string (VERSION_ID from /etc/os-release)
# @kver_str: Kernel version string (output of 'uname -r')
# @flags: Optional statistics flags
#
# Print a 64 bit hexadecimal system level in a format as understood by firmware.
#
# The format is 0xabccddeeeeffgghh, where
# - a=statistics flags
# - b=distro id
# - c=distro major version
# - d=distro minor version(s)
# - e=kernel sublevel 2
# - f=kernel version
# - g=kernel patchlevel
# - h=kernel sublevel 1
#
get_system_level()
{
	local distro="$1" ver_str="$2" kver_str="$3" flags="${4:-0}"
	local distro_id d_major d_minor d_minor2
	local k_ver k_patchlvl k_sublvl k_sublvl2 bytes=()

	# Extract list of sub-version numbers from version strings
	read -r d_major d_minor d_minor2 <<< "$(split_version "$ver_str" "._-" 3)"
	read -r k_ver k_patchlvl k_sublvl k_sublvl2 <<< "$(split_kver "$kver_str" 4)"

	# Apply distro-specific logic
	case "$distro" in
	"rhel")
		distro_id=$DISTRO_RHEL
		;;

	"sles")
		distro_id=$DISTRO_SLES
		;;

	"ubuntu")
		distro_id=$DISTRO_UBUNTU

		# Encode minor and update version numbers in minor field
		(( d_minor=((d_minor & 0xf) * 0x10) + (d_minor2 & 0xf) ))
		;;

	"fedora")
		distro_id=$DISTRO_FEDORA
		;;

	"opensuse-leap")
		distro_id=$DISTRO_OPENSUSE
		;;

	"debian")
		distro_id=$DISTRO_DEBIAN
		;;

	"rhcos")
		distro_id=$DISTRO_RHCOS
		;;

	*)
		distro_id=$DISTRO_GENERIC

		# Reset unsupported fields
		d_major=0
		d_minor=0
		k_sublvl2=0
		;;
	esac

	# Assemble byte data
	(( bytes[0] = (flags & 0xf) * 0x10 + distro_id ))
	(( bytes[1] = d_major ))
	(( bytes[2] = d_minor ))
	(( bytes[3] = (k_sublvl2 / 256) & 0xff ))
	(( bytes[4] = k_sublvl2 & 0xff ))
	(( bytes[5] = k_ver ))
	(( bytes[6] = k_patchlvl ))
	(( bytes[7] = k_sublvl ))

	# Print as single hex word
	bytes_to_word "${bytes[@]}"
}

get_distro()
{
	local line ID="linux" VERSION_ID="0" VERSION="" update

	[[ ! -e "$OS_RELEASE" ]] && return

	# Only import required variables
	while read -r line ; do
		if [[ "$line" =~ ^ID= ]] || [[ "$line" =~ ^VERSION_ID= ]] ||
		   [[ "$line" =~ ^VERSION= ]] ; then
			eval "$line"
		fi
	done <"$OS_RELEASE"

	if [[ "$ID" == "ubuntu" ]] ; then
		# Extract update version number only found in VERSION, e.g.
		# VERSION_ID="18.04" VERSION="18.04.5 LTS"
		update="${VERSION/*$VERSION_ID/}"
		update="${update%% *}"
		VERSION_ID="$VERSION_ID$update"
	fi

	echo "$ID:$VERSION_ID"
}

cpi_set_oslevel()
{
	local level="${1:-}"
	local flags list distro_id distro_ver kver id ver

	if [[ "$level" =~ ^0x ]] && ! [[ "$level" =~ : ]] ; then
		# Format: level=0x<hex>
		printf -v LEVEL "0x%016x" "$level" 2>/dev/null ||
			fail_with "$PRG: Invalid hexadecimal number in $level" \
				  $EXIT_INVALID_CHARS
		return
	fi

	# Format: level=[[[flags:]distro_id:distro_ver:]kver]
	IFS=":" read -r -a list <<< "$level:"
	kver="${list[*]: -1: 1}"
	distro_ver="${list[*]: -2: 1}"
	distro_id="${list[*]: -3: 1}"
	flags="${list[*]: -4: 1}"

	if [[ -z "$kver" ]] ; then
		# Use version of currently running kernel
		kver="$(uname -r)"
	fi

	if [[ -z "$distro_ver" ]] || [[ -z "$distro_id" ]] ; then
		# Use distro ID and version from os-release file
		IFS=":" read -r id ver <<< "$(get_distro)"
		distro_id=${distro_id:-$id}
		distro_ver=${distro_ver:-$ver}
	fi

	if [[ -z "$flags" ]] ; then
		# Keep statistics flags from current system level
		flags=$(( (LEVEL >> 60) & 0xf ))
	fi

	LEVEL=$(get_system_level "$distro_id" "$distro_ver" "$kver" "$flags")
}

cpi_set_type()
{
	TYPE="$1"
	do_length_check "$TYPE" "system type"
	do_character_check "$TYPE" "system type"
}

cpi_set_sysplex()
{
	SYSPLEX="$1"
	do_length_check "$SYSPLEX" "sysplex name"
	do_character_check "$SYSPLEX" "sysplex name"
}

cpi_set_name()
{
	NAME="$1"
	do_length_check "$NAME" "system name"
	do_character_check "$NAME" "system name"
}

# cpictl starts here

if [ $# -le 0 ]; then
	echo "$PRG: No parameters specified"
	print_parse_error_and_exit
fi

opts=$(getopt -o b:ehL:N:S:T:v -l set-bit:,environment,help,level:,name:,sysplex:,type:,commit,dry-run,show,version -n $PRG -- "$@")
if [ $? -ne 0 ]; then
	print_parse_error_and_exit
fi

# This guarantees that only one instance will be running, and will serialize
# the execution of multiple instances
[ -e "$CPI_LOCK" -a ! -w "$CPI_LOCK" ] &&
	fail_with "$PRG: Cannot access lock file: $CPI_LOCK"
[ ! -w "${CPI_LOCK%/*}" ] &&
	fail_with "$PRG: Cannot access lock file: $CPI_LOCK"

exec 9<> "$CPI_LOCK"
flock -x 9

# Get current values from sys/firmware
read LEVEL < "$SYSTEM_LEVEL_PATH"
read TYPE < "$SYSTEM_TYPE_PATH"
read NAME < "$SYSTEM_NAME_PATH"
read SYSPLEX < "$SYSPLEX_NAME_PATH"

# Parse command line options: Use eval to remove getopt quotes
eval set -- $opts
while [ -n $1 ]; do
	case "$1" in
	--help|-h)
		print_help_and_exit
		;;
	--version|-v)
		print_version_and_exit
		;;
	-b|--set-bit)
		case "$2" in
		kvm)
			cpi_set_bit 0
			;;
		*)
			fail_with "$PRG: Unknown bit \"$2\" for the $1 option"
			;;
		esac
		shift 2
		;;
	-L|--level)
		cpi_set_oslevel "$2"
		shift 2
		;;
	-e|--environment)
		cpi_set_type "$CPI_SYSTEM_TYPE"
		cpi_set_name "$CPI_SYSTEM_NAME"
		cpi_set_oslevel "$CPI_SYSTEM_LEVEL"
		cpi_set_sysplex "$CPI_SYSPLEX_NAME"
		shift
		;;
	-T|--type)
		cpi_set_type "$2"
		shift 2
		;;
	-S|--sysplex)
		cpi_set_sysplex "$2"
		shift 2
		;;
	-N|--name)
		cpi_set_name "$2"
		shift 2
		;;
	--show)
		cpi_show
		exit $EXIT_SUCCESS
		;;
	--commit)
		cpi_commit
		exit $EXIT_SUCCESS
		;;
	--dry-run)
		DRYRUN=1
		shift
		;;
	--)
		shift
		break
		;;
	*)
		break;
		;;
	esac
done

# Print settings for --dry-run or commit them to sysfs otherwise

if [ $DRYRUN -eq 1 ]; then
	cat <<-EndDryrun
	System type:  $TYPE
	System level: $LEVEL
	System name:  $NAME
	Sysplex name: $SYSPLEX
	EndDryrun
else
	echo "$LEVEL" > "$SYSTEM_LEVEL_PATH"
	echo "$TYPE" > "$SYSTEM_TYPE_PATH"
	echo "$NAME" > "$SYSTEM_NAME_PATH"
	echo "$SYSPLEX" > "$SYSPLEX_NAME_PATH"
	cpi_commit
fi

exit $EXIT_SUCCESS
