#!/bin/bash
#
# Copyright (c) 2021 The Flatcar Maintainers.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

# CI automation common functions.

source ci-automation/ci-config.env
: ${docker:=docker}

: ${TEST_WORK_DIR:='__TESTS__'}

function check_version_string() {
    local version="$1"

    if [[ ! "${version}" =~ ^(main|alpha|beta|stable|lts)-[0-9]+\.[0-9]+\.[0-9]+(-.+)?$ ]]; then
        echo "ERROR: invalid version '${version}', must start with 'main', 'alpha', 'beta', 'stable' or 'lts', followed by a dash and three dot-separated numbers, optionally followed by a dash and a non-empty build ID"
        exit 1
    fi
}
# --

function update_and_push_version() {
    local version=${1}
    local target_branch=${2:-}

    # set up author and email so git does not complain when tagging
    if ! git config --get user.name >/dev/null 2>&1 ; then
        git config user.name "${CI_GIT_AUTHOR}"
    fi
    if ! git config --get user.email >/dev/null 2>&1 ; then
        git config user.email "${CI_GIT_EMAIL}"
    fi

    # Add and commit local changes
    git add "sdk_container/.repo/manifests/version.txt"

    git commit --signoff --allow-empty -m "New version: ${version}"

    git fetch --all --tags --force
    local -i ret=0
    git diff --quiet --exit-code "${version}" 2>/dev/null || ret=${?}
    # This will return != 0 if
    #  - the remote tag does not exist (rc: 127)
    #  - the tag does not exist locally (rc: 128)
    #  - the remote tag has changes compared to the local tree (rc: 1)
    if [[ ret -eq 0 ]]; then
        # this means that we created an empty commit above, reusing the tag gets rid of it
        echo "Reusing existing tag" >&2
        git checkout --force "${version}"
        return
    elif [[ ret -eq 1 ]]; then
        echo "Remote tag exists already and is not equal" >&2
        return 1
    elif [[ ret -ne 127 && ret -ne 128 ]]; then
        echo "Error: Unexpected git diff return code (${ret})" >&2
        return 1
    fi

    local -a TAG_ARGS=()
    if [[ ${SIGN-0} = 1 ]]; then
        TAG_ARGS=("--sign" "--message=${version}")
    fi

    git tag --force "${TAG_ARGS[@]}" "${version}"

    if [[ -n ${target_branch} ]]; then
        git push origin "HEAD:${target_branch}"
    fi

    git push origin "${version}"
}
# --

function check_bincache_images_existence() {
    case ${CIA_DEBUGIMAGESEXIST:-} in
        'yes') return 0;;
        'no' ) return 1;;
        '') curl --head --fail --fail-early --silent --show-error --location "${@}" || return 1;;
        *) echo "Invalid CIA_DEBUGIMAGESEXIST value (${CIA_DEBUGIMAGESEXIST@Q})" >&2; exit 1;;
    esac
}
# --

function copy_from_buildcache() {
    local what="$1"
    local where_to="$2"

    mkdir -p "$where_to"
    curl --fail --silent --show-error --location --retry-delay 1 --retry 60 \
        --retry-connrefused --retry-max-time 60 --connect-timeout 20 \
        --remote-name --output-dir "${where_to}" "https://${BUILDCACHE_SERVER}/${what}" 
}
# --

function gen_sshcmd() {
    echo -n "ssh -o BatchMode=yes"
    echo -n " -o StrictHostKeyChecking=no"
    echo -n " -o UserKnownHostsFile=/dev/null"
    echo    " -o NumberOfPasswordPrompts=0"
}
# --

function copy_dir_from_buildcache() {
    local remote_path="${BUILDCACHE_PATH_PREFIX}/$1"
    local local_path="$2"

    local sshcmd="$(gen_sshcmd)"
    mkdir -p "${local_path}"
    rsync --partial -a -e "${sshcmd}" "${BUILDCACHE_USER}@${BUILDCACHE_SERVER}:${remote_path}" \
        "${local_path}"
}
# --

# Returns true if the passed directory contains files that match any
# of the passed glob strings. Remember to pass the glob as strings
# (means: quote them, so globs are not resolved at the function
# callsite).
function dir_contains_globs() (
    shopt -s nullglob

    local dir=${1}; shift
    # rest are globs

    # this is in a subshell, so cd is safe - won't mess with working
    # directory of the caller
    cd "${dir}" 2>/dev/null || return 1
    # for globs to be evaluated, we must not use quotes here
    local -a files=( ${@} )

    if [[ ${#files[@]} -gt 0 ]]; then
        return 0
    fi
    return 1
)
# --

function copy_to_buildcache() {
    local remote_path="${BUILDCACHE_PATH_PREFIX}/$1"
    shift

    local sshcmd="$(gen_sshcmd)"

    $sshcmd "${BUILDCACHE_USER}@${BUILDCACHE_SERVER}" \
        "mkdir -p ${remote_path}"

    rsync --partial -a -e "${sshcmd}" "$@" \
        "${BUILDCACHE_USER}@${BUILDCACHE_SERVER}:${remote_path}"
}
# --

function image_exists_locally() {
    local name="$1"
    local version="$2"
    local image="${name}:${version}"

    local image_exists="$($docker images "${image}" \
                            --no-trunc --format '{{.Repository}}:{{.Tag}}')"

    [ "${image}" = "${image_exists}" ]
}
# --

# Derive docker-safe image version string from vernum.
# Keep in sync with sdk_lib/sdk_container_common.sh
function vernum_to_docker_image_version() {
    local vernum="$1"
    echo "$vernum" | sed 's/[+]/-/g'
}
# --

# Return the full name (repo+name+tag) of an image. Useful for SDK images
#  pulled from the registry (which have the registry pre-pended)
function docker_image_fullname() {
    local image="$1"
    local version="$2"

    $docker images --no-trunc --format '{{.Repository}}:{{.Tag}}' \
        | grep -E "^(${CONTAINER_REGISTRY}/)*${image}:${version}$"
}
# --

function docker_image_to_buildcache() {
    local image="$1"
    local version="$2"

    # strip potential container registry prefix
    local tarball="$(basename "$image")-${version}.tar.zst"
    local id_file="$(basename "$image")-${version}.id"

    $docker save "${image}":"${version}" | zstd -T0 -o "${tarball}"

    # It is necessary to chmod from 0600 to 0644 to make it readable
    # afterwards by rsync during the release process. Zstd sets the mode
    # of the output file to 0600 in case of temporary files to avoid race
    # condition. See also https://github.com/facebook/zstd/pull/1644,
    # https://github.com/facebook/zstd/pull/3432.
    chmod 0644 "${tarball}"

    # Cut the "sha256:" prefix that is present in Docker but not in Podman
    $docker image inspect "${image}":"${version}" | jq -r '.[].Id' | sed 's/^sha256://' > "${id_file}"
    create_digests "${SIGNER:-}" "${tarball}" "${id_file}"
    sign_artifacts "${SIGNER:-}" "${tarball}"* "${id_file}"*
    copy_to_buildcache "containers/${version}" "${tarball}"* "${id_file}"*
}
# --

function docker_commit_to_buildcache() {
    local container="$1"
    local image_name="$2"
    local image_version="$3"

    $docker commit "${container}" "${image_name}:${image_version}"
    docker_image_to_buildcache "${image_name}" "${image_version}"
}
# --

function docker_image_from_buildcache() {
    local name="$1"
    local version="$2"
    local compr="${3:-zst}"
    local tgz="${name}-${version}.tar.${compr}"
    local id_file="${name}-${version}.id"
    local id_file_url="https://${BUILDCACHE_SERVER}/containers/${version}/${id_file}"
    local id_file_url_release="https://mirror.release.flatcar-linux.net/containers/${version}/${id_file}"

    local local_image=""
    if image_exists_locally "${name}" "${version}" ; then
        local_image="${name}:${version}"
    elif image_exists_locally "${CONTAINER_REGISTRY}/${name}" "${version}" ; then
        local_image="${CONTAINER_REGISTRY}/${name}:${version}"
    fi

    if [[ -n "${local_image}" ]] ; then
        local image_id=""
        image_id=$($docker image inspect "${local_image}" | jq -r '.[].Id' | sed 's/^sha256://')
        local remote_id=""
        remote_id=$(curl --fail --silent --show-error --location --retry-delay 1 \
                    --retry 60 --retry-connrefused --retry-max-time 60 --connect-timeout 20 \
                    "${id_file_url}" \
                    || curl --fail --silent --show-error --location --retry-delay 1 \
                    --retry 60 --retry-connrefused --retry-max-time 60 --connect-timeout 20 \
                    "${id_file_url_release}" \
                    || echo "not found")
        if [ "${image_id}" = "${remote_id}" ]; then
          echo "Local image is up-to-date" >&2
          return
        fi
        echo "Local image outdated, downloading..." >&2
    fi

    # First try bincache then release to allow a bincache overwrite
    local url="https://${BUILDCACHE_SERVER}/containers/${version}/${tgz}"
    local url_release="https://mirror.release.flatcar-linux.net/containers/${version}/${tgz}"

    local curl_progress=(--silent --show-error)
    if [[ -t 2 ]]; then
        curl_progress=(--progress-bar)
    fi

    curl --fail "${curl_progress[@]}" --location --retry-delay 1 --retry 60 \
        --retry-connrefused --retry-max-time 60 --connect-timeout 20 \
        --remote-name "${url}" \
        || curl --fail "${curl_progress[@]}" --location --retry-delay 1 --retry 60 \
        --retry-connrefused --retry-max-time 60 --connect-timeout 20 \
        --remote-name "${url_release}"

    # zstd can handle zlib as well :)
    zstd -d -c ${tgz} | $docker load

    rm "${tgz}"
}
# --

function docker_image_from_registry_or_buildcache() {
    local image="$1"
    local version="$2"

    if $docker pull "${CONTAINER_REGISTRY}/${image}:${version}" ; then
        return
    fi

    echo "Container image not found in registry, downloading SDK tarball instead (this is normal for nightly builds)..." >&2
    docker_image_from_buildcache "${image}" "${version}" zst || \
        docker_image_from_buildcache "${image}" "${version}" gz
}
# --

# Called by vendor test in case of complete failure not eligible for
# reruns (like trying to run tests on unsupported architecture).
function break_retest_cycle() {
    local work_dir=$(dirname "${PWD}")
    local dir=$(basename "${work_dir}")

    if [[ "${dir}" != "${TEST_WORK_DIR}" ]]; then
        echo "Not breaking retest cycle, expected test work dir to be a parent directory" >&2
        return
    fi
    touch "${work_dir}/break_retests"
}
# --

# Called by test runner to see if the retest cycle should be broken.
function retest_cycle_broken() {
    # Using the reverse boolean logic here!
    local broken=1
    if [[ -f "${TEST_WORK_DIR}/break_retests" ]]; then
        broken=0
        rm -f "${TEST_WORK_DIR}/break_retests"
    fi
    return ${broken}
}
# --

# Substitutes fields in the passed template and prints the
# result. Followed by the template, the parameters used for
# replacement are in alphabetical order: arch, channel, proto and
# vernum.
function url_from_template() {
    local template="${1}"; shift
    local arch="${1}"; shift
    local channel="${1}"; shift
    local proto="${1}"; shift
    local vernum="${1}"; shift
    local url="${template}"

    url="${url//@ARCH@/${arch}}"
    url="${url//@CHANNEL@/${channel}}"
    url="${url//@PROTO@/${proto}}"
    url="${url//@VERNUM@/${vernum}}"

    echo "${url}"
}
# --

# Puts a secret into a file, while trying for the secret to not end up
# on a filesystem at all. A path to the file with the secret in /proc
# in put into the chosen variable. The secret is assumed to be
# base64-encoded.
#
# Typical use:
#   secret_file=''
#   secret_to_file secret_file "${some_secret}"
#
# Parameters:
# 1 - name of the variable where the path is stored
# 2 - the secret to store in the file
function secret_to_file() {
    local config_var_name="${1}"; shift
    local secret="${1}"; shift
    local tmpfile=$(mktemp)
    local -n config_ref="${config_var_name}"
    local fd

    exec {fd}<>"${tmpfile}"
    rm -f "${tmpfile}"
    echo "${secret}" | base64 --decode >&${fd}
    # Use BASHPID because we may be in a subshell but $$ is only the main shell's PID
    config_ref="/proc/${BASHPID}/fd/${fd}"
}
# --

# Creates signatures for the passed files and directories. In case of
# directory, all files inside are signed. Files ending with .asc or
# .sig or .gpg are ignored, though. This function is a noop if signer
# is empty.
#
# Typical use:
#   sign_artifacts "${SIGNER}" artifact.tar.gz
#   copy_to_buildcache "artifacts/directory" artifact.tar.gz*
#
# Parameters:
#
# 1 - signer whose key is expected to be already imported into the
#       keyring
# @ - files and directories to sign
function sign_artifacts() {
    local signer="${1}"; shift
    # rest of the parameters are directories/files to sign
    local to_sign=()
    local file

    if [[ -z "${signer}" ]]; then
        return
    fi

    list_files to_sign 'asc,gpg,sig' "${@}"

    for file in "${to_sign[@]}"; do
        gpg --batch --local-user "${signer}" \
            --output "${file}.sig" \
            --detach-sign "${file}"
    done
}
# --

# Creates digests files and armored ASCII files out of them for the
# passed files and directories. In case of directory, all files inside
# it are processed. No new digests file is created if there is one
# already for the processed file. Same for armored ASCII file. Files
# ending with .asc or .sig or .gpg or .DIGESTS are not processed. The
# armored ASCII files won't be created if the signer is empty.
#
# Typical use:
#   create_digests "${SIGNER}" artifact.tar.gz
#   sign_artifacts "${SIGNER}" artifact.tar.gz*
#   copy_to_buildcache "artifacts/directory" artifact.tar.gz*
#
# Parameters:
#
# 1 - signer whose key is expected to be already imported into the
#       keyring
# @ - files and directories to create digests for
function create_digests() {
    local signer="${1}"; shift
    # rest of the parameters are files or directories to create
    # digests for
    local to_digest=()
    local file
    local df
    local fbn
    local hash_type
    local output
    local af

    list_files to_digest 'asc,gpg,sig,DIGESTS' "${@}"

    for file in "${to_digest[@]}"; do
        df="${file}.DIGESTS"
        if [[ ! -e "${df}" ]]; then
            touch "${df}"
            fbn=$(basename "${file}")
            # TODO: modernize - drop md5 and sha1, add b2
            for hash_type in md5 sha1 sha512; do
                echo "# ${hash_type} HASH" | tr "a-z" "A-Z" >>"${df}"
                output=$("${hash_type}sum" "${file}")
                echo "${output%% *}  ${fbn}" >>"${df}"
            done
        fi
        if [[ -z "${signer}" ]]; then
            continue
        fi
        af="${df}.asc"
        if [[ ! -e "${af}" ]]; then
            gpg --batch --local-user "${signer}" \
                --output "${af}" \
                --clearsign "${df}"
        fi
    done
}
# --

# Puts a filtered list of files from the passed files and directories
# in the passed variable. The filtering is done by ignoring files that
# end with the passed extensions. The extensions list should not
# contain the leading dot.
#
# Typical use:
#   local all_files=()
#   local ignored_extensions='sh,py,pl' # ignore the shell, python and perl scripts
#   list_files all_files "${ignored_extensions}" "${directories_and_files[@]}"
#
# Parameters:
#
# 1 - name of an array variable where the filtered files will be stored
# 2 - comma-separated list of extensions that will be used for filtering files
# @ - files and directories to scan for files
function list_files() {
    local files_variable_name="${1}"; shift
    local ignored_extensions="${1}"; shift
    # rest of the parameters are files or directories to list
    local -n files="${files_variable_name}"
    local file
    local tmp_files
    local pattern=''

    if [[ -n "${ignored_extensions}" ]]; then
        pattern='\.('"${ignored_extensions//,/|}"')$'
    fi

    files=()
    for file; do
        tmp_files=()
        if [[ -d "${file}" ]]; then
            readarray -d '' tmp_files < <(find "${file}" ! -type d -print0)
        elif [[ -e "${file}" ]]; then
            tmp_files+=( "${file}" )
        fi
        if [[ -z "${pattern}" ]]; then
            files+=( "${tmp_files[@]}" )
            continue
        fi
        for file in "${tmp_files[@]}"; do
            if [[ "${file}" =~ ${pattern} ]]; then
                continue
            fi
            files+=( "${file}" )
        done
    done
}
# --

# Applies ../scripts.patch to the current repo.
function apply_local_patches() {
  local patch_file="../scripts.patch"
  local patch_id
  echo "Looking for local patches ${patch_file}"
  patch_id=$(test -e "${patch_file}" && { cat "${patch_file}" | git patch-id | cut -d ' ' -f 1 ; } || true)
  if [ "${patch_id}" != "" ]; then
    if git "${dirarg[@]}" log --no-merges -p HEAD | git patch-id | cut -d ' ' -f 1 | grep -q "${patch_id}"; then
      echo "Skipping already applied ${patch_file}"
    else
      echo "Applying ${patch_file}"
      GIT_COMMITTER_NAME="Flatcar Buildbot" GIT_COMMITTER_EMAIL="buildbot@flatcar-linux.org" git am -3 "$PWD/${patch_file}"
    fi
  fi
}
# --

# Returns 0 if passed value is either "1" or "true" or "t", otherwise
# returns 1.
function bool_is_true() {
    case "${1}" in
        1|true|t|yes|y)
            return 0
            ;;
        *)
            return 1
            ;;
    esac
}
# --
