proxmox-utils/.pct-helpers
Alex A. Naanou 5393b8dd4b wprking on per-ct config...
Signed-off-by: Alex A. Naanou <alex.nanou@gmail.com>
2024-10-27 15:21:56 +03:00

1040 lines
18 KiB
Bash

#!/usr/bin/bash
#----------------------------------------------------------------------
#
#
#----------------------------------------------------------------------
CT_DIR=${CT_DIR:=/etc/pve/lxc/}
# XXX setup path...
# XXX
EDITOR=${EDITOR:-nano}
LAST_RUN_CONFIG=config.last-run
#----------------------------------------------------------------------
# XXX this is quite generic, might be a good idea to move this to a
# seporate lib/file...
# Execute (optionally) and print a command.
#
# @ COMMAND ARGS
#
#QUIET=
#DRY_RUN=
ECHO_PREFIX="### "
function @ (){
if [ -z $DRY_RUN ] ; then
! [ $QUIET ] \
&& echo -e "${ECHO_PREFIX}$@"
"$@"
else
echo -e $@
fi
return $?
}
#----------------------------------------------------------------------
#
# check MSG COMMAND ..
#
check(){
local MSG=$1
shift
for cmd in "$@" ; do
which $cmd > /dev/null 2>&1 \
|| eval "echo \"$MSG\"" >&2
done
}
need(){
check 'ERROR: "$cmd": needed by this script but not in path.' "$@"
}
would-like(){
check 'WARNING: "$cmd": is not in path.' "$@"
}
#----------------------------------------------------------------------
# Fill section...
#
# XXX this is quite generic -- move to a more logical place...
fillsection(){ (
usage(){
echo "Usage:"
echo " ${FUNCNAME[0]} [-h]"
echo " ${FUNCNAME[0]} [-r] NAME FILE [CONTENT]"
echo
}
while true ; do
case $1 in
-h|--help)
usage
echo "Options:"
# . . .
echo " -h | --help print this help message and exit."
echo " -r | --return replace section markers with CONTENT."
echo
return 0
;;
-r|--replace)
local replace=1
shift
;;
*)
break
;;
esac
done
if [[ $# < 2 ]] ; then
usage
return 1
fi
name=$1
file=$2
content=$3
content=${content:=/dev/stdin}
# print file upto section marker...
if [ $replace ] ; then
sed "/${name^^} BEGIN/q" "$file" | sed '$d'
else
sed "/${name^^} BEGIN/q" "$file"
fi
# print content...
cat $content
# print file from section end marker...
if [ $replace ] ; then
sed -ne "/${name^^} END/,$ p" "$file" | sed '1d'
else
sed -ne "/${name^^} END/,$ p" "$file"
fi
) }
#----------------------------------------------------------------------
# CT hostname <-> CT id...
ct2hostname(){
local ct=${CT_DIR}/${1}.conf
local host=$(cat $ct \
| grep -e '^\s*hostname:' \
| head -1)
echo ${host/hostname: /}
}
hostname2ct(){
if [ -e "${CT_DIR}/${1}.conf" ] ; then
echo $1
fi
local running=$2
running=${running:=any}
local ct
local host
for ct in "${CT_DIR}"/*.conf ; do
host=$(cat $ct | grep hostname | head -1)
host=${host/hostname: /}
if [ "$host" = $1 ] ; then
ct=${ct#${CT_DIR}}
ct=${ct%.conf}
ct=${ct#\/}
# filter results if needed...
if [ $running = "any" ] ; then
echo $ct
else
local status=`pct status $ct`
if [ "$running" = "${status/status: /}" ] ; then
echo $ct
fi
fi
fi
done
}
#----------------------------------------------------------------------
normpath(){
echo $1 \
| sed \
-e 's/\/\+/\//g' \
-e 's/\/.\//\//g' \
-e 's/[^\/]\+\/\.\.//g' \
-e 's/\/\+/\//g' \
-e 's/\/\.$/\//g'
}
#----------------------------------------------------------------------
#
# xread [-n] MSG VAR
#
# This saves all user input variables to the $XREAD_VARS array.
xread(){
local non_empty=
if [[ $1 == '-n' ]] ; then
shift
local non_empty=1
fi
local prefix=
if [ $SCRIPTING ] ; then
prefix='# '
fi
# skip...
if [[ "${!2}" == "SKIP" ]] \
|| [[ "$(eval "echo \$DFL_$2")" == "SKIP" ]] ; then
eval "$2="
return
fi
if [ -z ${!2} ] ; then
eval 'read -ep "'$prefix''$1'" -i "$DFL_'$2'" '${2}''
XREAD_VARS+=(${2})
fi
if [ -z $non_empty ] ; then
eval ''$2'=${'$2':=$DFL_'$2'}'
fi
[ $SCRIPTING ] \
&& echo "$2=${!2}"
}
#
# xreadYes MSG [VAR]
#
xreadYes(){
if [ -z ${2} ] ; then
local var=__LOCAL
local __LOCAL
else
local var=${2}
local mode=
fi
local prefix=
if [ $SCRIPTING ] ; then
prefix='# '
fi
# XXX check DFL_..???
if [[ "${!var}" == "SKIP" ]] ; then
eval "$var="
return 1
fi
if [ -z ${!var} ] ; then
if [[ "$(eval "echo \$DFL_${var}")" == "SKIP" ]] ; then
eval "$var="
return 1
elif [ -z $(eval "echo \$DFL_${var}") ] ; then
local yes=y
local no=N
local dfl=
else
local yes=Y
local no=n
local dfl=1
fi
eval 'read -ep "'$prefix''$1' ('$yes'/'$no') " '${var}''
XREAD_VARS+=(${var})
# normalize...
eval "${var}=${!var,,}"
if [[ "${!var}" == 'y' ]] ; then
eval "${var}=1"
elif [[ ${!var} == 'n' ]] ; then
eval "${var}="
# set default if empty...
else
eval "${var}=\${${var}:-$dfl}"
fi
fi
[ $SCRIPTING ] \
&& [[ "$var" != '__LOCAL' ]] \
&& echo "$var=${!var}"
if [ -z ${!var} ] ; then
return 1
fi
}
#
# xreadpass [msg] VAR
#
xreadpass(){
local msg
if [[ $# == 2 ]] ; then
msg="$1 "
shift
fi
if [[ ${!1} == 'SKIP' ]] ; then
return
fi
local prefix=
if [ $SCRIPTING ] ; then
prefix='# '
fi
local PASS1
local PASS2
for attempt in 1 2 3 ; do
read -sep "${prefix}${msg}password (Enter to skip): " PASS1
echo
if [ -z $PASS1 ] ; then
return
fi
read -sep "${prefix}retype password: " PASS2
echo
if [[ $PASS1 != $PASS2 ]] ; then
echo "ERROR: passwords do not match." >&2
continue
fi
eval ''$1'='${PASS1}''
return
done
return 1
}
# Like cat but a prettier...
#
# listFile PATH
#
listFile(){
if [ -e "$1" ] ; then
echo "--- $1 ---"
cat "$1"
echo '---'
else
echo "$FUNCNAME: $1: No such file or directory."
return 1
fi
}
# Review changes in PATH.new, then edit/apply changes to PATH
#
# reviewApplyChanges PATH [apply|edit|skip]
#
#
# NOTE: if changes are not applied this will return non-zero making this
# usable in conditionals...
reviewApplyChanges(){
local file=$1
if ! [ -e "$file".new ] ; then
echo "$FUNCNAME: $1: No such file or directory."
return 1
fi
# default option...
local dfl=
local a=a
local e=e
local s=s
case "${2,,}" in
a|apply)
a=A
dfl=a
;;
e|edit)
e=E
dfl=e
;;
s|skip)
s=S
dfl=s
;;
esac
echo "# Review updated: ${file}.new:"
listFile ${file}.new
local res
while true ; do
read -ep "# [$a]pply, [$e]dit, [$s]kip? " res
if [ -z $res ] ; then
if [ -z $dfl ] ; then
continue
fi
res=$dfl
fi
case "${res,,}" in
a|apply)
break
;;
e|edit)
${EDITOR} "${file}.new"
listFile ${file}.new
;;
s|skip)
echo "# Changes kept as: ${file}.new"
return 1
;;
*)
echo "ERROR: Unknown command: \"$res\"" >&2
continue
;;
esac
done
@ mv -b "${file}"{.new,}
}
#----------------------------------------------------------------------
#
# readCTConfig
#
# XXX list or load a specific CT config...
readCTConfig(){
if [ -z $ID ] && ! [ -z $CTHOSTNAME ] ; then
# XXX select by id...
true
elif ! [ -z $ID ] && [ -z $CTHOSTNAME ] ; then
# XXX select by hostname...
true
else
# XXX list all...
return
fi
local ct_cfg=$ID-$CTHOSTNAME.cfg
if [ -e $ct_cfg ] ; then
source "$ct_cfg"
fi
}
#
# readConfig
#
# Envioronment variables:
# CLEAN_RUN - if set ignore ./config.last-run
# CONFIG - config file to load last
#
# XXX need this not to make this behave with explicitly set vars...
readConfig(){
if [ -z $NO_DEFAULTS ] ; then
local IFS=$'\n'
#__ENV=($( (set -o posix ; set | grep -v 'BASHOPTS=') ))
#__ENV=($( (declare -xp) ))
[ -e ../config.global ] \
&& source ../config.global
[ -e ./config ] \
&& source ./config
# XXX is this the right priority for this???
[ -e ./$LAST_RUN_CONFIG ] \
&& [ -z $CLEAN_RUN ] \
&& source ./$LAST_RUN_CONFIG
[ -e "$CONFIG" ] \
&& source $CONFIG
#eval "${__ENV[@]}"
#__ENV=
fi
}
#
# saveConfig [-d|-a] CONFIG VAR ..
#
saveConfig(){
local prefix=
local append=
while true ; do
case $1 in
-d|--default)
prefix=DFL_
shift
;;
-a|--append)
append=1
shift
;;
*)
break
;;
esac
done
local cfg=$1
shift
if [ -z $append ] ; then
printf '' > "$cfg"
fi
{
for var in $@ ; do
echo "${prefix}${var}=${!var}"
done
echo
} >> "$cfg"
}
saveLastRunConfig(){
local cfg=$LAST_RUN_CONFIG
local ct_cfg
if ! [ -z $ID ] && ! [ -z $CTHOSTNAME ] ; then
local ct_cfg=$ID-$CTHOSTNAME.cfg
fi
echo "# Saving config to: $cfg ${ct_cfg:+and $ct_cfg}"
{
echo "#"
echo "# This file is auto-generated, any changes here will be overwritten."
echo "#"
} > "$cfg"
saveConfig -d -a "$cfg" ${XREAD_VARS[@]}
if ! [ -z ${ct_cfg} ] ; then
cp "$cfg" "$ct_cfg"
fi
}
#
# webAppConfig NAME
#
webAppConfig(){
local name=${1^^}
eval "${name}_SUBDOMAIN=\${${name}_SUBDOMAIN:=\${DFL_SUB${name}_DOMAIN}}
${name}_SUBDOMAIN=\${${name}_SUBDOMAIN:+\${${name}_SUBDOMAIN%.}.}
${name}_DOMAIN=\${${name}_DOMAIN:=\${DFL_${name}_DOMAIN}}
# prioretize \${name}_*
DFL_DOMAIN=\${DFL_DOMAIN:+\${${name}_SUBDOMAIN}\${DFL_DOMAIN}}
DFL_DOMAIN=\${DOMAIN:+\${${name}_SUBDOMAIN}\${DOMAIN}}
if [ \$${name}_DOMAIN ] ; then
DFL_DOMAIN=\${${name}_SUBDOMAIN}\${${name}_DOMAIN}
fi"
# force check of domain...
DOMAIN=
}
#
# readVars
# readCTHardwareVars
# readBridgeVars
#
# Variables this handles:
# EMAIL
# DOMAIN
# ID
# CTHOSTNAME
# WAN_BRIDGE
# LAN_BRIDGE
# ADMIN_BRIDGE
# WAN_IP
# WAN_GATE
# LAN_IP
# LAN_GATE
# ADMIN_IP
# ADMIN_GATE
# ROOTPASS
# PCT_EXTRA
#
# Variables this sets:
# PASS
#
# Variables used:
# TMP_PASS_LEN
# ROOTPASS
#
readCTVars(){
xread "ID: " ID
xread "Hostname: " CTHOSTNAME
# hardware...
xread "CPU cores: " CORES
xread "RAM (MB): " RAM
xread "SWAP (MB): " SWAP
xread "DRIVE (GB): " DRIVE
}
readBridgeVars(){
# bridge config...
xread "ADMIN bridge: vmbr" ADMIN_BRIDGE
xread "WAN bridge: vmbr" WAN_BRIDGE
xread "LAN bridge: vmbr" LAN_BRIDGE
}
readVars(){
xread -n "Email: " EMAIL
xread -n "Domain: " DOMAIN
xread -n "Gate ID: " GATE_ID
readCTVars
readBridgeVars
# gateway...
# IPs can be:
# <empty>
# <IP>/<mask>
# dhcp
# Gateways can be:
# <empty>
# <IP>
# XXX these are the same...
xread "WAN ip: " WAN_IP
if [[ $WAN_IP != "dhcp" ]] ; then
xread "WAN gateway: " WAN_GATE
else
WAN_GATE=
fi
xread "LAN ip: " LAN_IP
if [[ $LAN_IP != "dhcp" ]] ; then
xread "LAN gateway: " LAN_GATE
else
LAN_GATE=
fi
xread "ADMIN ip: " ADMIN_IP
if [[ $ADMIN_IP != "dhcp" ]] ; then
xread "ADMIN gateway: " ADMIN_GATE
else
ADMIN_GATE=
fi
# root password...
if [ -z $ROOTPASS ] ; then
xreadpass root PASS \
|| exit 1
else
PASS=$ROOTPASS
fi
# extra stuff...
xread "pct extra options: " PCT_EXTRA
}
#
# makeTemplateSEDPatterns VAR ...
#
makeTemplateSEDPatterns(){
local var
for var in "$@" ; do
local val=${!var}
if [[ $val == SKIP ]] ; then
val=
fi
echo "-e 's/\\\${${var}}/${val//\//\\/}/g'"
done
}
# same as makeTemplateSEDPatterns but adds default vars + generates *_IPn vars...
PCT_TEMPLATE_VARS=(
EMAIL
DOMAIN
CTHOSTNAME
HOST_ADMIN_IP
GATE_HOSTNAME
GATE_LAN_IP
GATE_ADMIN_IP
NS_HOSTNAME
NS_LAN_IP
NS_ADMIN_IP
WAN_IP
WAN_GATE
LAN_IP
LAN_GATE
ADMIN_IP
ADMIN_GATE
)
makePCTTemplateSEDPatterns(){
local vars=("${PCT_TEMPLATE_VARS[@]}" "$@")
# strip ips and save to *_IPn var...
local ip_vars=()
local var
local val
for var in ${vars[@]} ; do
if [[ $var =~ .*_IP ]] ; then
local val=${!var}
if [[ $val == SKIP ]] ; then
val=
fi
ip_vars+=("${var}n")
eval "local ${var}n=\"${val/\/*}\""
fi
done
makeTemplateSEDPatterns "${vars[@]}" "${ip_vars[@]}"
}
#
# expandTemplate PATH VAR ...
# .. | expandTemplate VAR ...
#
PCT_TEMPLATE_PATTERNS=
expandTemplate(){
if [ -t 0 ] ; then
local input=$1
shift
else
local input=/dev/stdin
fi
if [ -z "$PCT_TEMPLATE_PATTERNS" ] ; then
local patterns=($(makeTemplateSEDPatterns "$@"))
else
local patterns=("${PCT_TEMPLATE_PATTERNS[@]}")
fi
cat "${input}" \
| eval "sed ${patterns[@]}"
}
#
# expandPCTTemplate PATH [VAR ...]
# .. | expandPCTTemplate [VAR ...]
#
expandPCTTemplate(){
local input=
if [ -t 0 ] ; then
input=$1
shift
fi
local PCT_TEMPLATE_PATTERNS=($(makePCTTemplateSEDPatterns "$@"))
expandTemplate "${input}"
}
#
# buildAssets [VAR ..]
#
# XXX add vars in filenames (???)
NOTES=NOTES.md
buildAssets(){
local template_dir=${TEMPLATE_DIR:-templates}
local assets_dir=${ASSETS_DIR:-assets}
local staging_dir=${STAGING_DIR:-staging}
local PCT_TEMPLATE_PATTERNS=($(makePCTTemplateSEDPatterns "$@"))
# assets...
if [ -e "${assets_dir}" ] ; then
mkdir -p "${staging_dir}"
cp -R "${assets_dir}"/* "${staging_dir}"/
fi
# template dir...
if [ -e $template_dir ] ; then
local TEMPLATES=($(find "$template_dir" -type f))
for file in "${TEMPLATES[@]}" ; do
file=${file#${template_dir}}
echo Generating: ${file}...
[ $DRY_RUN ] \
&& continue
# ensure the directory exists...
mkdir -p "$(dirname "${staging_dir}/${file}")"
cat "${template_dir}/${file}" \
| expandTemplate \
> "${staging_dir}/${file}"
done
fi
# special case: NOTES.md...
if [ -z "$DESCRIPTION" ] && [ -e "$NOTES" ] ; then
DESCRIPTION="$(\
cat ${NOTES} \
| expandTemplate)"
fi
}
#----------------------------------------------------------------------
#
# pctPushAssets ID
#
pctPushAssets(){
@ pct-push-r $1 "${STAGING_DIR:-./staging}" /
}
#
# traefikPushConfig
#
# XXX generate config in a staging location...
TRAEFIK_CONFIG=traefik.yml
TRAEFIK_PATH=/etc/traefik.d/
TRAEFIK_STAGING=traefik/
traefikPushConfig(){
local filename="${CTHOSTNAME}.yml"
local source="${TRAEFIK_STAGING}/${filename}"
local target="${TRAEFIK_PATH}"/"${filename}"
# source file not found...
if ! [ -e "${TRAEFIK_CONFIG}" ] ; then
echo "${TRAEFIK_CONFIG}: not found." >&2
return
fi
# generat config...
mkdir -p "${TRAEFIK_STAGING}"
cat ${TRAEFIK_CONFIG} \
| expandPCTTemplate \
> "${source}"
# get things we need if they are not set...
xread "Gate CT id: " GATE_ID
# check if $filename exists...
if @ lxc-attach $GATE_ID -- test -e ${target} \
&& ! xreadYes "Overwrite existing \"${target}\"?" ; then
@ lxc-attach $GATE_ID -- mv "${target}" "${target}.bak"
fi
@ pct push $GATE_ID "${source}" "${target}"
}
#
# pveGetLatestTemplate PATTERN [VAR]
#
# see:
# https://pve.proxmox.com/wiki/Linux_Container
pveGetLatestTemplate(){
if [ $DRY_RUN ] ; then
[ -z $2 ] \
|| eval "$2=${CT_TEMPLATE:-\\\$CT_TEMPLATE}"
return
fi
#@ pveam update
local templates=($(pveam available | grep -o ''${1}'.*$'))
local latest=${templates[-1]}
@ pveam download local ${latest}
latest=$(pveam list local | grep -o "^.*$latest")
#latest=($(ls /var/lib/vz/template/cache/${1}*))
[ -z $2 ] \
|| eval "$2=${latest}"
}
#
# pctBaseCreate ID TEMPLATE ARGS [PASS]
#
pctBaseCreate(){
local ID=$1
local TEMPLATE=$2
local ARGS=$3
local PASS=$4
local TMP_PASS=$(cat /dev/urandom | base64 | head -c ${TMP_PASS_LEN:=32})
# NOTE: we are not setting the password here to avoid printing it to the terminal...
@ pct create $ID \
"${TEMPLATE}" \
${ARGS} \
--password="$TMP_PASS" \
--start 1 \
|| exit 1
# set actual root password...
if [ "$PASS" ] ; then
echo "root:$PASS" \
| @ lxc-attach $ID chpasswd
fi
}
#
# pctCreate ID TEMPLATE [PASS]
#
#OPTS_STAGE_1=
#INTERFACES=
#CTHOSTNAME=
#CORES=
#RAM=
#SWAP=
#DRIVE=
#PCT_EXTRA=
pctCreate(){
# build network args...
local interfaces_args=()
local i=0
local interface
for interface in "${INTERFACES[@]}" ; do
interfaces_args+=("--net${i} "${interface}"")
i=$(( i + 1 ))
done
# NOTE: TKL gui will not function correctly without nesting enabled...
local args="\
--hostname $CTHOSTNAME \
--cores $CORES \
--memory $RAM \
--swap $SWAP \
"${interfaces_args[@]}" \
--storage local-lvm \
--rootfs local-lvm:$DRIVE \
--unprivileged 1 \
--features nesting=1 \
${PCT_EXTRA} \
"
pctBaseCreate "$1" "$2" "${OPTS_STAGE_1:-"${args}"}" "$3"
}
#
# pctCreate<distro> ID [PASS]
#
pctCreateAlpine(){
local TEMPLATE
pveGetLatestTemplate alpine TEMPLATE
pctCreate $1 "$TEMPLATE" "$2"
sleep ${TIMEOUT:=5}
@ lxc-attach $1 apk update
@ lxc-attach $1 apk upgrade
}
pctCreateDebian(){
local TEMPLATE
pveGetLatestTemplate 'debian-12-standard' TEMPLATE
pctCreate $1 "$TEMPLATE" "$2"
sleep ${TIMEOUT:=5}
@ lxc-attach $1 apt update
@ lxc-attach $1 -- apt upgrade -y
}
pctCreateUbuntu(){
local TEMPLATE
pveGetLatestTemplate ubuntu TEMPLATE
pctCreate $1 "$TEMPLATE" "$2"
sleep ${TIMEOUT:=5}
@ lxc-attach $1 apt update
@ lxc-attach $1 -- apt upgrade -y
}
#
# pctCreateTurnkey APP ID [PASS]
#
pctCreateTurnkey(){
local app=$1
shift
local TEMPLATE
pveGetLatestTemplate '.*-turnkey-'$app TEMPLATE
pctCreate $1 "$TEMPLATE" "$2"
tklWaitForSetup $1
sleep ${TIMEOUT:=5}
}
# Wait for /etc/inithooks.conf to be generated then cleared
#
# tklWaitForSetup ID
#
# for tkl inithooks doc see:
# https://www.turnkeylinux.org/docs/inithooks
tklWaitForSetup(){
printf "# TKL setup, this may take a while"
if [ -z $DRY_RUN ] ; then
while ! $(lxc-attach $1 -- test -e /etc/inithooks.conf) ; do
printf '.'
sleep ${TIMEOUT:=5}
done
printf '+'
sleep ${TIMEOUT:=5}
while ! [[ $(lxc-attach $1 -- cat /etc/inithooks.conf | wc -c) < 2 ]] ; do
printf '.'
sleep ${TIMEOUT:=5}
done
else
printf '.+..'
fi
printf 'ready.\n'
sleep ${TIMEOUT:=5}
}
#
# pctUpdateTurnkey ID
#
pctUpdateTurnkey(){
@ lxc-attach $1 apt update
@ lxc-attach $1 -- apt upgrade -y
}
#
# pctSet ID [ARGS [REBOOT]]
#
pctSet(){
[ "$2" ] \
&& @ pct set $1 \
${2}
[ "$3" ] \
&& @ pct reboot $1
}
#
# pctSetNotes ID [DESCRIPTION]
#
pctSetNotes(){
# XXX for some reason this complains quote alot...
#[ "$DESCRIPTION" ] \
# && @ pct set $1 \
# "${DESCRIPTION:+--description \""${DESCRIPTION}"\"}"
local ID=$1
local NOTES="$(\
echo -e "${2:-${DESCRIPTION}}" \
| sed -e 's/^/#/')"
if [ "$DRY_RUN" ] ; then
return
fi
local CONF="$(cat "${CT_DIR}/${ID}.conf")"
local TEXT="\
"${NOTES}"
"${CONF}"
"
echo -e "${TEXT}" > "${CT_DIR}/${ID}.conf"
}
#
# showNotes [VAR ...]
#
BUILD_NOTES=BUILD_NOTES
showNotes(){
[ -e "${BUILD_NOTES}" ] \
&& mv "${BUILD_NOTES}"{,.bak}
[ -e "${BUILD_NOTES}".tpl ] \
&& ( cat "${BUILD_NOTES}".tpl \
| expandPCTTemplate $@ \
| tee "${BUILD_NOTES}" )
}
#
# pushNotes ID
#
pushNotes(){
[ -e "${BUILD_NOTES}" ] \
&& @ pct-push-r $1 "${BUILD_NOTES}" /root/
}
#----------------------------------------------------------------------
# vim:set ts=4 sw=4 nowrap :