sh: Add a new "cros_payload" to manage resources for installers.

For better managing how factory resource will be installed, we need an
unified system for different installation scenario (RMA-USB,
Shim/Network(Omaha), Netboot, Copy-Machine, ...).

The new 'cros_payload' defined a new way to keep storage for
installation of disk and file resources.

To use this:

 1. Create a JSON config file with only '{}' in resources folder.
 2. cros_payload add board.json firmware ~/Downloads/chromeos-firmwareupdate
 3. cros_payload add board.json release_image ~/Downloads/*recovery*.bin
 4. cros_payload add board.json test_image ~/Downloads/*test_image*.bin

Check board.json which should have reference to gzipped contents.

 5. Get a empty USB stick, say /dev/sde, and format properly:
 6. cros_payload install board.json /dev/sde release_image
 7. cros_payload install board.json /dev/sde test_image

Performance (tested on z840):

time ./cros_payload add src/t.json release_image \
  ~/Downloads/chromeos_8198.0.0_chell_recovery_dev-channel_mp.bin

INFO: Adding component release_image part 1 (18M)...
INFO: Adding component release_image part 2 (16M)...
INFO: Adding component release_image part 3 (1290M)...
...

real    0m7.262s
user    1m38.181s
sys     0m12.961s

time ./cros_payload add src/t.json test_image \
  ~/Downloads/chromiumos_test_image.bin

INFO: Adding component test_image part 1 (1216M)...
INFO: Adding component test_image part 2 (16M)...
INFO: Adding component test_image part 3 (2000M)...
...

real    0m16.571s
user    3m25.217s
sys     0m31.313s

BUG=chromium:711615
TEST=None

Change-Id: Iaf8b401eec6f27c21abb7306ff6e28e292cf6b06
Reviewed-on: https://chromium-review.googlesource.com/463127
Commit-Ready: Hung-Te Lin <hungte@chromium.org>
Tested-by: Hung-Te Lin <hungte@chromium.org>
Reviewed-by: Pi-Hsun Shih <pihsun@chromium.org>
diff --git a/bin/cros_payload b/bin/cros_payload
new file mode 120000
index 0000000..e884afb
--- /dev/null
+++ b/bin/cros_payload
@@ -0,0 +1 @@
+../sh/cros_payload.sh
\ No newline at end of file
diff --git a/setup/cros_payload b/setup/cros_payload
new file mode 120000
index 0000000..e884afb
--- /dev/null
+++ b/setup/cros_payload
@@ -0,0 +1 @@
+../sh/cros_payload.sh
\ No newline at end of file
diff --git a/sh/cros_payload.sh b/sh/cros_payload.sh
new file mode 100755
index 0000000..590983c
--- /dev/null
+++ b/sh/cros_payload.sh
@@ -0,0 +1,625 @@
+#!/bin/sh
+#
+# Copyright 2017 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+# 'cros_payload' is a tool to manipulate resources for imaging ChromeOS device.
+# Run 'cros_payload' to get help information.
+
+# This tool must be self-contained with minimal dependency. And at least the
+# 'installation' part must be implemented in shell script instead of Python
+# because the ChromeOS factory install shim and netboot installer will need to
+# run installation without python.
+
+# External dependencies:
+#  jq curl md5sum partx|cgpt pigz|gzip
+#  dd tee od chmod dirname readlink mktemp stat
+#  cp mv ln rm
+
+# TODO(hungte) List of todo:
+# - Quick check dependency before starting to run.
+# - Add partitions in parallel if pigz cannot be found.
+# - Consider using xz/pixz instead of gz.
+# - Support adding or removing single partition directly.
+# - Add part0 as GPT itself.
+
+# Environment settings for utilities to invoke.
+: "${GZIP:="gzip"}"
+: "${JQ:=""}"
+
+# Debug settings
+: "${DEBUG:=}"
+
+# Constants
+COMPONENTS_ALL="test_image release_image toolkit hwid firmware"
+
+# A variable for the file name of tracking temp files.
+TMP_OBJECTS=""
+
+# Cleans up any temporary files we have created.
+# Usage: cleanup
+cleanup() {
+  trap - EXIT
+  local object
+  if [ -n "${TMP_OBJECTS}" ]; then
+    while read object; do
+      if [ -d "${object}" ]; then
+        umount -d "${object}" 2>/dev/null
+      fi
+      rm -f "${object}"
+    done <"${TMP_OBJECTS}"
+    rm -f "${TMP_OBJECTS}"
+  fi
+}
+
+# Prints error message and try to abort.
+# Usage: die [messages]
+die() {
+  trap - EXIT
+  echo "ERROR: $*" >&2
+  cleanup
+  exit 1
+}
+
+# Prints information to users.
+# Usage: info [message]
+info() {
+  echo "INFO: $*" >&2
+}
+
+# Prints debug messages if ${DEBUG} was set to non-empty values.
+# Usage: debug [message]
+debug() {
+  [ -z "${DEBUG}" ] || echo "DEBUG: $*" >&2
+}
+
+# Registers a temp object to be deleted on cleanup.
+# Usage: register_tmp_object TEMP_FILE
+register_tmp_object() {
+  # Use file-based temp object tracker so execution in sub-shell (command |
+  # while read var) will also work.
+  echo "$*" >>"${TMP_OBJECTS}"
+}
+
+# Checks if given file is already compressed by gzip.
+# Usage: is_gzipped FILE
+is_gzipped() {
+  local input="$1"
+
+  # The 'file' command needs special database, so we want to directly read the
+  # magic value.
+  local magic="$(od -An -N2 -x "${input}")"
+  local gzip_magic=" 8b1f"
+
+  [ "${magic}" = "${gzip_magic}" ]
+}
+
+# Checks if a tool program is already available on path.
+# Usage: has_tool PROGRAM
+has_tool() {
+  type "$1" >/dev/null 2>&1
+}
+
+# Downloads from given URL. If output is not given, use STDOUT.
+# Usage: fetch URL [output]
+fetch() {
+  local url="$1"
+  local output="$2"
+  if [ -n "${output}" ]; then
+    curl -L --fail "${url}" -o "${output}"
+  else
+    curl -L --fail "${url}"
+  fi
+}
+
+# Prints a path for specific partition on given device.
+# For example, "get_partition_dev /dev/mmcblk0 1" prints /dev/mmcblk0p1.
+# Usage: get_partition_dev DEV PART_NO
+get_partition_dev() {
+  local base="$1"
+  shift
+
+  # TODO(hungte) decide if we should also test -b ${base}.
+  case "${base}" in
+    *[0-9])
+      # Adjust from /dev/mmcblk0 to /dev/mmcblk0p
+      echo "${base}p$*"
+      ;;
+    "")
+      die "Need valid partition device"
+      ;;
+    *)
+      echo "${base}$*"
+  esac
+}
+
+# Command "help", provides usage help.
+# Usage: cmd_help
+cmd_help() {
+  echo "Usage: $0 command [args...]
+
+  '$(basename "$0")' is an utility for manipulating payload-type resources.
+  All the payloads will be stored as gzipped file with MD5SUM in
+  file name. A JSON file manages the mapping to individual files.
+
+  Commands:
+      add      JSON_PATH COMPONENT FILE
+      install  JSON_URL  DEST     [COMPONENTS...]
+      list     JSON_URL
+
+  COMPONENT: ${COMPONENTS_ALL}
+  JSON_PATH: A path to local JSON config file.
+  JSON_URL:  A URL to remote or local JSON config file.
+  FILE:      A path to local file resource.
+  DEST:      Destination (usually a folder or a block device like /dev/sda).
+  "
+}
+
+# JSON helper functions - using jq or python.
+
+# Merges two json arguments into out.
+json_merge() {
+  local base_path="$1"
+  local update_path="$2"
+
+  if [ -n "${JQ}" ]; then
+    jq -s '.[0] * .[1]' "${base_path}" "${update_path}"
+    return
+  fi
+
+  python -c "\
+import json
+import sys
+
+def get_fd(path):
+  return sys.stdin if path == '-' else open(path)
+
+def merge(base, delta):
+  for key, value in delta.iteritems():
+    if isinstance(value, dict):
+      new_value = base.setdefault(key, {})
+      merge(new_value, value)
+    else:
+      base[key] = value
+  return base
+
+base = json.load(get_fd(sys.argv[1]))
+delta = json.load(get_fd(sys.argv[2]))
+merge(base, delta)
+print(json.dumps(base))
+" "${base_path}" "${update_path}"
+}
+
+# Gets the value of specified query command.
+json_get_value() {
+  local query="$1"
+  local json_file="$2"
+  if [ -n "${JQ}" ]; then
+    "${JQ}" -r "${query}" "${json_file}"
+    return
+  fi
+
+  python -c "\
+import json
+import sys
+
+j = json.load(open(sys.argv[2]))
+for k in sys.argv[1].split('.')[1:]:
+  j = j.get(k) if j else None
+print('null' if j is None else j)" "${query}" "${json_file}"
+}
+
+# Gets the keys of given JSON object from stdin.
+json_get_keys() {
+  if [ -n "${JQ}" ]; then
+    "${JQ}" -r 'keys[]'
+    return
+  fi
+
+  python -c "import json; import sys; print('\n'.join(json.load(sys.stdin)))"
+}
+
+# Updates JSON data to specified config.
+update_json() {
+  local json_path="$1"
+  local new_json="$2"
+
+  local new_config="$(mktemp)"
+  register_tmp_object "${new_config}"
+
+  echo "${new_json}" | json_merge "${json_path}" - >"${new_config}"
+  cp -f "${new_config}" "${json_path}"
+  chmod a+r "${json_path}"
+}
+
+# Commits a payload into given location.
+# Usage: commit_payload COMPONENT SUBTYPE MD5SUM TEMP_PAYLOAD DIR
+commit_payload() {
+  local component="$1"
+  local subtype="$2"
+  local md5sum="$3"
+  local temp_payload="$4"
+  local dir="$5"
+  local json output_name
+
+  # Derived variables
+  [ -n "${md5sum}" ] || die "Fail to get MD5 for ${component}.${subtype}."
+  [ -e "${temp_payload}" ] || die "Fail to find temporary file ${temp_payload}."
+
+  if [ -n "${subtype}" ]; then
+    output_name="${component}_${subtype}_${md5sum}.gz"
+    json="{\"${component}\": {\"${subtype}\": \"${output_name}\"}}"
+  else
+    output_name="${component}_${md5sum}.gz"
+    json="{\"${component}\": \"${output_name}\"}"
+  fi
+  local output="${dir}/${output_name}"
+
+  # Ideally TEMP_PAYLOAD should be deleted by cleanup, and we do want to prevent
+  # deleting it in order to prevent race condition if multiple instances of this
+  # program is running (so mktemp won't reuse names).
+  rm -f "${output}"
+  ln "${temp_payload}" "${output}" || cp -f "${temp_payload}" "${output}"
+
+  chmod a+r "${output}"
+  update_json "${json_path}" "${json}"
+}
+
+# Adds an image partition type payload.
+# Usage: add_image_part JSON_PATH COMPONENT FILE PART_NO START SIZE
+#  FILE is the disk image file.
+#  PART_NO is the number (NR) of partition number.
+#  START is the starting sector (bs=512) of partition.
+#  SIZE is the number of sectors in partition.
+add_image_part() {
+  local json_path="$1"
+  local component="$2"
+  local file="$3"
+  local nr="$4"
+  local start="$5"
+  local sectors="$6"
+
+  local md5sum=""
+  local output=""
+  local output_dir="$(dirname "$(readlink -f "${json_path}")")"
+
+  local tmp_file="$(mktemp -p "${output_dir}" tmp_XXXXXX.gz)"
+  register_tmp_object "${tmp_file}"
+
+  # TODO(hungte) if nr is 3, fetch lsb-release version.
+  info "Adding component ${component} part ${nr} ($((sectors / 2048))M)..."
+  md5sum="$(
+    dd if="${file}" bs=512 skip="${start}" count="${sectors}" 2>/dev/null | \
+    "${GZIP}" -qc | tee "${tmp_file}" | md5sum -b)"
+
+  commit_payload "${component}" "part${nr}" "${md5sum%% *}" \
+    "${tmp_file}" "${output_dir}"
+
+}
+
+# Adds an disk image type payload.
+# Usage: add_image_component JSON_PATH COMPONENT FILE
+add_image_component() {
+  local json_path="$1"
+  local component="$2"
+  local file="$3"
+  local nr start sectors uuid part_command
+
+  # TODO(hungte) Support image in compressed form (for example, test image in
+  # tar.xz) using tar -O.
+  if has_tool cgpt; then
+    part_command="cgpt show -q -n"
+  elif has_tool partx; then
+    # The order must be same as what CGPT outputs.
+    part_command="partx -g -r -o START,SECTORS,NR,UUID"
+  else
+    die "Missing partition tools - please install cgpt or partx."
+  fi
+
+  ${part_command} "${file}" | while read start sectors nr uuid; do
+    debug "${part_command} ${file} -> ${start} ${sectors} ${nr} ${uuid}"
+    # ${uuid} is not really needed for add_image_part.
+    add_image_part "${json_path}" "${component}" "${file}" "${nr}" \
+      "${start}" "${sectors}"
+  done
+}
+
+# Adds a simple file type payload.
+# Usage: add_file_component JSON_PATH COMPONENT FILE
+add_file_component() {
+  local json_path="$1"
+  local component="$2"
+  local file="$3"
+
+  local md5sum=""
+  local output=""
+  local file_size="$(($(stat -c "%s" "${file}") / 1048576))M"
+  local output_dir="$(dirname "$(readlink -f "${json_path}")")"
+
+  local tmp_file="$(mktemp -p "${output_dir}" tmp_XXXXXX.gz)"
+  register_tmp_object "${tmp_file}"
+
+  if is_gzipped "${file}"; then
+    # Simply copy to destination
+    info "Adding component ${component} (${file_size}, gzipped)..."
+    md5sum="$(tee "${tmp_file}" <"${file}" | md5sum -b)"
+  else
+    # gzip and copy at the same time. If we want to prevent storing original
+    # file name, add -n.
+    info "Adding component ${component} (${file_size})..."
+    md5sum="$("${GZIP}" -qc "${file}" | tee "${tmp_file}" | md5sum -b)"
+  fi
+  commit_payload "${component}" "" "${md5sum%% *}" "${tmp_file}" "${output_dir}"
+}
+
+# Command "add", to add a component into payloads.
+# Usage: cmd_add JSON_PATH COMPONENT FILE
+cmd_add() {
+  local json_path="$1"
+  local component="$2"
+  local file="$3"
+
+  if [ ! -e "${json_path}" ]; then
+    die "Invalid JSON config path: ${json_path}"
+  fi
+  if [ ! -e "${file}" ]; then
+    die "Missing input file: ${file}"
+  fi
+
+  case "${component}" in
+    release_image | test_image)
+      add_image_component "${json_path}" "${component}" "${file}"
+      ;;
+    firmware | hwid | toolkit)
+      add_file_component "${json_path}" "${component}" "${file}"
+      ;;
+    *)
+      die "Unknown component: ${component}"
+      ;;
+  esac
+}
+
+# Installs a disk image partition type payload to given location.
+# Usage: install_partition JSON_URL DEST JSON_FILE COMPONENT MAPPINGS...
+install_partition() {
+  local json_url="$1"; shift
+  local dest="$1"; shift
+  local json_file="$1"; shift
+  local component="$1"; shift
+  local json_url_base="$(dirname "${json_url}")"
+  local remote_file="" remote_url="" dest_part_dev=""
+  local mapping part_from part_to
+
+  # Each mapping comes in "from_NR to_NR" format.
+  # TODO(hungte) Install partitions in parallel if pigz is not available.
+  for mapping in "$@"; do
+    part_from="${mapping% *}"
+    part_to="${mapping#* }"
+    remote_file="$( \
+      json_get_value ".${component}.part${part_from}" "${json_file}")"
+    if [ "${remote_file}" = "null" ]; then
+      die "Missing payload ${component}.part${part_from} from ${json_url}."
+    fi
+    remote_url="${json_url_base}/${remote_file}"
+    dest_part_dev="$(get_partition_dev "${dest}" "${part_to}")"
+    [ -b "${dest_part_dev}" ] || die "Not a block device: ${dest_part_dev}"
+    info "Installing from ${component}#${part_from} to ${dest_part_dev} ..."
+    # TODO(hungte) Support better dd/pv, pre-fetch size.
+    # bs is fixed on 1048576 because many dd implementations do not support
+    # units like '1M' or '1m'.
+    fetch "${remote_url}" | "${GZIP}" -d | \
+      dd of="${dest_part_dev}" bs=1048576 iflag=fullblock oflag=dsync
+  done
+}
+
+# Adds a stub file for component installation.
+# Usage: install_add_stub DIR COMPONENT
+install_add_stub() {
+  local payloads_dir="$1"
+  local component="$2"
+  local output_dir="${payloads_dir}/install"
+  # Chrome OS test images may disable symlink and +exec on stateful partition,
+  # so we have to implement the stub as pure shell scripts, and invoke the
+  # component via shell.
+  local stub="${output_dir}/${component}.sh"
+  local command="sh ./${component}"
+
+  case "${component}" in
+    toolkit)
+      command="sh ./${component} --yes"
+      ;;
+    hwid)
+      ;;
+    *)
+      return
+  esac
+
+  # Decompress now to reduce installer dependency.
+  "${GZIP}" -d "${payloads_dir}/${component}.gz"
+
+  mkdir -m 0755 -p "${output_dir}"
+  echo '#!/bin/sh' >"${stub}"
+  # shellcheck disable=SC2016
+  echo 'cd "$(dirname "$(readlink -f "$0")")"/..' >>"${stub}"
+  echo "${command}" >>"${stub}"
+}
+
+# Installs a file type payload to given location.
+# Usage: install_file JSON_URL DEST COMPONENT
+install_file() {
+  local json_url="$1"; shift
+  local dest="$1"; shift
+  local json_file="$1"; shift
+  local component="$1"; shift
+  local json_url_base="$(dirname "${json_url}")"
+  local output=""
+
+  local remote_file="$(json_get_value ".${component}" "${json_file}")"
+  local remote_url="${json_url_base}/${remote_file}"
+
+  if [ -d "${dest}" ]; then
+    # The destination is a directory.
+    output="${dest}/${component}.gz"
+    echo "Installing from ${component} to ${output} ..."
+    fetch "${remote_url}" "${dest}/${component}.gz"
+  elif [ -b "${dest}" ]; then
+    local dev="$(get_partition_dev "${dest}" 1)"
+    if [ ! -b "${dev}" ]; then
+      # The destination is a block device file for partition.
+      dev="${dest}"
+    fi
+    local mount_point="$(mktemp -d)"
+    register_tmp_object "${mount_point}"
+    mount "${dev}" "${mount_point}"
+
+    echo "Installing from ${component} to ${dev}!cros_payloads/${component}.gz"
+    local out_dir="${mount_point}/cros_payloads"
+    mkdir -p "${out_dir}"
+    output="${out_dir}/${component}.gz"
+    fetch "${remote_url}" "${output}"
+    install_add_stub "${output}" "${component}"
+    umount "${mount_point}"
+  elif [ "${dest%.gz}" = "${dest}" ]; then
+    # The destination is an uncompressed file.
+    output="${dest}"
+    echo "Installing from ${component} to ${output} ..."
+    fetch "${remote_url}" | "${GZIP}" -d >"${output}"
+  else
+    # The destination is a compressed file.
+    output="${dest}.gz"
+    echo "Installing from ${component} to ${output} ..."
+    fetch "${remote_url}" "${output}"
+  fi
+}
+
+# Prints a curl friendly canonical URL of given argument.
+# Usage: get_canonical_url URL
+get_canonical_url() {
+  local url="$*"
+
+  case "${url}" in
+    *"://"*)
+      echo "$*"
+      ;;
+    "")
+      die "Missing URL."
+      ;;
+    *)
+      echo "file://$(readlink -f "${url}")"
+      ;;
+  esac
+}
+
+# Command "install", to allow installing components to target.
+# Usage: cmd_install JSON_URL DEST_DEV [COMPONENTS...]
+# When not specified, try to install all components available.
+cmd_install() {
+  local json_url="$1"; shift
+  local dest="$1"; shift
+  [ -n "${json_url}" ] || die "Need JSON URL."
+  if [ ! -e "${dest}" ] && [ ! -e "$(dirname "${dest}")" ]; then
+    die "Need existing destination (${dest})."
+  fi
+  local component
+  local components="$*"
+
+  local json_file="$(mktemp)"
+  register_tmp_object "${json_file}"
+
+  if [ -z "${components}" ]; then
+    components="${COMPONENTS_ALL}"
+  fi
+  json_url="$(get_canonical_url "${json_url}")"
+
+  info "Getting JSON config from ${json_url}..."
+  fetch "${json_url}" "${json_file}"
+
+  # All ChromeOS USB image sources have
+  #  2 = Recovery Kernel
+  #  3 = Root FS
+  #  4 = Normal Kernel
+  # And the installation on fixed storage should be
+  #  2 = Test Image Normal Kernel
+  #  3 = Test Image Root FS
+  #  4 = Release Image Normal Kernel
+  #  5 = Release Image Root FS
+
+  for component in ${components}; do
+    case "${component}" in
+      test_image)
+        install_partition \
+          "${json_url}" "${dest}" "${json_file}" "${component}" \
+          "1 1" "4 2" "3 3"
+        ;;
+      release_image)
+        install_partition \
+          "${json_url}" "${dest}" "${json_file}" "${component}" \
+          "4 4" "3 5" \
+          "6 6" "7 7" "8 8" "9 9" "10 10" "11 11" "12 12"
+        ;;
+      toolkit | hwid | firmware)
+        install_file \
+          "${json_url}" "${dest}" "${json_file}" "${component}" \
+          ""
+        ;;
+      *)
+        # TODO(hungte) In future we may be able to install arbitrary component
+        # from JSON file.
+        die "Unknown component: ${component}"
+    esac
+  done
+
+  # TODO(hungte) Complete installation.
+}
+
+# Lists available components on JSON URL.
+# Usage: cmd_list "$@"
+cmd_list() {
+  local json_url="$1"
+  json_url="$(get_canonical_url "${json_url}")"
+
+  info "Getting JSON config from ${json_url}..."
+  fetch "${json_url}" | json_get_keys
+}
+
+# Main entry.
+# Usage: main "$@"
+main() {
+  if [ "$#" -lt 2 ]; then
+    cmd_help
+    exit
+  fi
+  set -e
+  trap "die Execution failed." EXIT
+
+  if has_tool pigz; then
+    GZIP="pigz"
+  fi
+  if has_tool jq; then
+    JQ="jq"
+  fi
+  umask 022
+
+  TMP_OBJECTS="$(mktemp)"
+  case "$1" in
+    add)
+      shift
+      cmd_add "$@"
+      ;;
+    install)
+      shift
+      cmd_install "$@"
+      ;;
+    list)
+      shift
+      cmd_list "$@"
+      ;;
+    *)
+      cmd_help
+      die "Unknown command: $1"
+      ;;
+  esac
+  trap cleanup EXIT
+}
+main "$@"