#!/usr/bin/env bash set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "$script_dir/../.." && pwd)" source "$repo_root/scripts/backup/lib/common.sh" if [[ -f "$repo_root/.env" ]]; then env_override_keys=( DOCKER_IMAGE_REF IMAGE_REF WORKS_DOCKER_COMMIT_CONTAINER DOCKER_COMMIT_CONTAINER WORKS_DOCKER_IMAGE_ARCHIVE_DIR WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR WORKS_DRIVE_TARGET WORKS_DRIVE_SHARED_DRIVE_ID WORKS_DRIVE_PARENT_FILE_ID WORKS_DRIVE_USER_ID WORKS_DRIVE_GROUP_ID WORKS_DRIVE_SHARED_FOLDER_ID WORKS_DRIVE_ACCESS_TOKEN WORKS_DRIVE_ACCESS_TOKEN_FILE WORKS_DRIVE_ACCESS_TOKEN_CMD WORKS_DRIVE_OAUTH_SCOPE WORKS_DRIVE_OVERWRITE WORKS_DRIVE_DRY_RUN WORKS_DRIVE_CURL_BIN WORKS_DRIVE_SHAREDRIVE_ID WORKS_DRIVE_OAUTH_CLIENT_ID WORKS_DRIVE_OAUTH_CLIENT_SECRET WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE WORKS_DRIVE_OAUTH_REFRESH_TOKEN WORKS_SHAREDRIVE_ID WORKS_ADMIN_API_BASE_URL WORKS_ADMIN_OAUTH_TOKEN_URL ) declare -A env_override_values=() env_override_set=() for env_key in "${env_override_keys[@]}"; do if [[ -v "$env_key" ]]; then env_override_set+=("$env_key") env_override_values["$env_key"]="${!env_key}" fi done set -a # shellcheck source=/dev/null source "$repo_root/.env" set +a for env_key in "${env_override_set[@]}"; do printf -v "$env_key" '%s' "${env_override_values[$env_key]}" export "$env_key" done fi backup_require_command docker backup_require_command jq backup_require_command sha256sum backup_require_command stat backup_require_command zstd image_ref="${DOCKER_IMAGE_REF:-${IMAGE_REF:-}}" [[ -n "$image_ref" ]] || backup_die "DOCKER_IMAGE_REF is required. Example: DOCKER_IMAGE_REF=registry.example/baron_sso/backend:v1.2606.ab12" commit_container="${WORKS_DOCKER_COMMIT_CONTAINER:-${DOCKER_COMMIT_CONTAINER:-}}" archive_root="${WORKS_DOCKER_IMAGE_ARCHIVE_DIR:-/tmp/baron-sso-docker-image-upload}" image_root_dir="${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-docker-build-image}" dry_run="${WORKS_DRIVE_DRY_RUN:-false}" target="${WORKS_DRIVE_TARGET:-sharedrive}" api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}" curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}" overwrite="${WORKS_DRIVE_OVERWRITE:-true}" upload_scope="${WORKS_DRIVE_OAUTH_SCOPE:-file}" WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_SHARED_DRIVE_ID:-${WORKS_DRIVE_SHAREDRIVE_ID:-${WORKS_SHAREDRIVE_ID:-}}}" urlencode_path() { jq -nr --arg value "$1" '$value|@uri' } split_curl_response() { local response="$1" local __body_var="$2" local __status_var="$3" local status local body status="$(tail -n 1 <<<"$response")" if [[ "$status" =~ ^[0-9][0-9][0-9]$ ]]; then body="$(sed '$d' <<<"$response")" else status="200" body="$response" fi printf -v "$__body_var" '%s' "$body" printf -v "$__status_var" '%s' "$status" } redact_for_log() { sed -E 's/("(access_token|refresh_token|assertion|client_secret|Authorization)"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"REDACTED"/Ig' } resolve_target_upload_endpoint() { local parent_file_id="${1:-${WORKS_DRIVE_PARENT_FILE_ID:-}}" local encoded_parent="" if [[ -n "$parent_file_id" ]]; then encoded_parent="$(urlencode_path "$parent_file_id")" fi case "$target" in sharedrive) [[ -n "${WORKS_DRIVE_SHARED_DRIVE_ID:-}" ]] || backup_die "WORKS_DRIVE_SHARED_DRIVE_ID is required when WORKS_DRIVE_TARGET=sharedrive." local shared_drive_id shared_drive_id="$(urlencode_path "$WORKS_DRIVE_SHARED_DRIVE_ID")" if [[ -n "$encoded_parent" ]]; then printf '%s/v1.0/sharedrives/%s/files/%s\n' "$api_base_url" "$shared_drive_id" "$encoded_parent" else printf '%s/v1.0/sharedrives/%s/files\n' "$api_base_url" "$shared_drive_id" fi ;; mydrive) local user_id="${WORKS_DRIVE_USER_ID:-me}" user_id="$(urlencode_path "$user_id")" if [[ -n "$encoded_parent" ]]; then printf '%s/v1.0/users/%s/drive/files/%s\n' "$api_base_url" "$user_id" "$encoded_parent" else printf '%s/v1.0/users/%s/drive/files\n' "$api_base_url" "$user_id" fi ;; group) [[ -n "${WORKS_DRIVE_GROUP_ID:-}" ]] || backup_die "WORKS_DRIVE_GROUP_ID is required when WORKS_DRIVE_TARGET=group." local group_id group_id="$(urlencode_path "$WORKS_DRIVE_GROUP_ID")" if [[ -n "$encoded_parent" ]]; then printf '%s/v1.0/groups/%s/folder/files/%s\n' "$api_base_url" "$group_id" "$encoded_parent" else printf '%s/v1.0/groups/%s/folder/files\n' "$api_base_url" "$group_id" fi ;; sharedfolder) [[ -n "${WORKS_DRIVE_SHARED_FOLDER_ID:-}" ]] || backup_die "WORKS_DRIVE_SHARED_FOLDER_ID is required when WORKS_DRIVE_TARGET=sharedfolder." local user_id="${WORKS_DRIVE_USER_ID:-me}" local shared_folder_id user_id="$(urlencode_path "$user_id")" shared_folder_id="$(urlencode_path "$WORKS_DRIVE_SHARED_FOLDER_ID")" if [[ -n "$encoded_parent" ]]; then printf '%s/v1.0/users/%s/drive/sharedfolders/%s/files/%s\n' "$api_base_url" "$user_id" "$shared_folder_id" "$encoded_parent" else printf '%s/v1.0/users/%s/drive/sharedfolders/%s/files\n' "$api_base_url" "$user_id" "$shared_folder_id" fi ;; *) backup_die "unknown WORKS_DRIVE_TARGET: $target" ;; esac } resolve_target_children_endpoint() { local parent_file_id="${1:-${WORKS_DRIVE_PARENT_FILE_ID:-}}" printf '%s/children\n' "$(resolve_target_upload_endpoint "$parent_file_id")" } resolve_target_create_folder_endpoint() { local parent_file_id="${1:-${WORKS_DRIVE_PARENT_FILE_ID:-}}" printf '%s/createfolder\n' "$(resolve_target_upload_endpoint "$parent_file_id")" } base64url() { openssl base64 -A | tr '+/' '-_' | tr -d '=' } build_jwt_assertion() { backup_require_command openssl local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" local service_account="${WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT:-}" local private_key="${WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY:-}" local private_key_file="${WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE:-}" local key_file="" local temp_key_file="" local now local exp local header local payload local signing_input [[ -n "$client_id" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_ID is required for service-account token mode." [[ -n "$service_account" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT is required for service-account token mode." if [[ -n "$private_key" ]]; then temp_key_file="$(mktemp /tmp/baron-sso-works-key.XXXXXX)" printf '%s\n' "$private_key" >"$temp_key_file" key_file="$temp_key_file" elif [[ -n "$private_key_file" ]]; then if [[ "$private_key_file" != /* ]]; then private_key_file="$repo_root/$private_key_file" fi backup_require_path "$private_key_file" key_file="$private_key_file" else backup_die "WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY or WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE is required for service-account token mode." fi now="$(date +%s)" exp="$((now + 3600))" header="$(printf '{"alg":"RS256","typ":"JWT"}' | base64url)" payload="$(jq -cn \ --arg iss "$client_id" \ --arg sub "$service_account" \ --argjson iat "$now" \ --argjson exp "$exp" \ '{iss:$iss, sub:$sub, iat:$iat, exp:$exp}' | base64url)" signing_input="${header}.${payload}" printf '%s' "$signing_input" \ | openssl dgst -sha256 -sign "$key_file" -binary \ | base64url \ | while IFS= read -r signature; do printf '%s.%s\n' "$signing_input" "$signature" done if [[ -n "$temp_key_file" ]]; then rm -f "$temp_key_file" fi } request_service_account_token() { local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" local token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}" local assertion local response local response_body local http_status [[ -n "$client_secret" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_SECRET is required for service-account token mode." assertion="$(build_jwt_assertion)" response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ --data-urlencode "assertion=$assertion" \ --data-urlencode "client_id=$client_id" \ --data-urlencode "client_secret=$client_secret" \ --data-urlencode "scope=$upload_scope" \ "$token_url")" split_curl_response "$response" response_body http_status if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then backup_die "WORKS token request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" fi jq -er '.access_token' <<<"$response_body" } request_refresh_access_token() { local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" local refresh_token="${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" local token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}" local response local response_body local http_status [[ -n "$client_id" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_ID is required for refresh-token mode." [[ -n "$client_secret" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_SECRET is required for refresh-token mode." [[ -n "$refresh_token" ]] || backup_die "WORKS_DRIVE_OAUTH_REFRESH_TOKEN is required for refresh-token mode." response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=refresh_token" \ --data-urlencode "refresh_token=$refresh_token" \ --data-urlencode "client_id=$client_id" \ --data-urlencode "client_secret=$client_secret" \ "$token_url")" split_curl_response "$response" response_body http_status if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then backup_die "WORKS refresh token request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" fi jq -er '.access_token' <<<"$response_body" } resolve_access_token() { if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN:-}" ]]; then printf '%s\n' "$WORKS_DRIVE_ACCESS_TOKEN" return fi if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN_FILE:-}" ]]; then backup_require_path "$WORKS_DRIVE_ACCESS_TOKEN_FILE" sed -n '1p' "$WORKS_DRIVE_ACCESS_TOKEN_FILE" return fi if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ]]; then sh -c "$WORKS_DRIVE_ACCESS_TOKEN_CMD" return fi if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then request_refresh_access_token return fi request_service_account_token } list_child_folders() { local access_token="$1" local endpoint="$2" local response local response_body local http_status response="$("$curl_bin" -sS -w $'\n%{http_code}' -X GET \ -H "Authorization: Bearer $access_token" \ "$endpoint")" split_curl_response "$response" response_body http_status if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then backup_die "WORKS folder list request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" fi printf '%s\n' "$response_body" } create_child_folder() { local access_token="$1" local endpoint="$2" local folder_name="$3" local payload local response local response_body local http_status payload="$(jq -cn --arg fileName "$folder_name" '{fileName:$fileName}')" response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ -H "Authorization: Bearer $access_token" \ -H "Content-Type: application/json; charset=UTF-8" \ -d "$payload" \ "$endpoint")" split_curl_response "$response" response_body http_status if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then backup_die "WORKS folder create request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" fi jq -er '.fileId // .id' <<<"$response_body" } ensure_child_folder() { local access_token="$1" local parent_file_id="$2" local folder_name="$3" local children_endpoint local create_folder_endpoint local children_json local folder_id children_endpoint="$(resolve_target_children_endpoint "$parent_file_id")" create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")" children_json="$(list_child_folders "$access_token" "$children_endpoint")" folder_id="$(jq -er --arg name "$folder_name" ' [ (.files // .children // .items // [])[] | select((.fileName // .name) == $name) | select(((.fileType // .type // "") | ascii_downcase) == "folder") | .fileId // .id ][0] // empty ' <<<"$children_json" 2>/dev/null || true)" if [[ -n "$folder_id" ]]; then printf '%s\n' "$folder_id" return fi create_child_folder "$access_token" "$create_folder_endpoint" "$folder_name" } ensure_folder_path() { local access_token="$1" local path="$2" local parent_file_id="${WORKS_DRIVE_PARENT_FILE_ID:-}" local component IFS='/' read -r -a components <<<"$path" for component in "${components[@]}"; do [[ -n "$component" ]] || continue parent_file_id="$(ensure_child_folder "$access_token" "$parent_file_id" "$component")" done printf '%s\n' "$parent_file_id" } create_upload_url() { local access_token="$1" local endpoint="$2" local file_path="$3" local file_name local file_size local payload local response local response_body local http_status file_name="$(basename "$file_path")" file_size="$(stat -c '%s' "$file_path")" payload="$(jq -cn \ --arg fileName "$file_name" \ --argjson fileSize "$file_size" \ --argjson overwrite "$overwrite" \ '{fileName:$fileName, fileSize:$fileSize, overwrite:$overwrite}')" response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ -H "Authorization: Bearer $access_token" \ -H "Content-Type: application/json; charset=UTF-8" \ -d "$payload" \ "$endpoint")" split_curl_response "$response" response_body http_status if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then backup_die "WORKS upload URL request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" fi jq -er '.uploadUrl' <<<"$response_body" } upload_file_to_url() { local access_token="$1" local upload_url="$2" local file_path="$3" local file_name local response local response_body local http_status file_name="$(basename "$file_path")" response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ -H "Authorization: Bearer $access_token" \ -F "Filedata=@${file_path};filename=${file_name};type=application/octet-stream" \ "$upload_url")" split_curl_response "$response" response_body http_status if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then backup_die "WORKS file upload failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" fi printf '%s\n' "$response_body" } derive_repository_and_tag() { local ref="$1" local ref_without_digest="${ref%@*}" local last_slash_index=-1 local last_colon_index=-1 local i local char local repository_with_registry local first_component local rest if [[ "$ref" == *@* ]]; then backup_die "digest image refs are not supported for WORKS image archive upload. Use a tagged image ref." fi for ((i = 0; i < ${#ref_without_digest}; i += 1)); do char="${ref_without_digest:i:1}" [[ "$char" == "/" ]] && last_slash_index="$i" [[ "$char" == ":" ]] && last_colon_index="$i" done if [[ "$last_colon_index" -le "$last_slash_index" ]]; then backup_die "DOCKER_IMAGE_REF must include an explicit tag: $ref" fi repository_with_registry="${ref_without_digest:0:last_colon_index}" image_tag="${ref_without_digest:last_colon_index + 1}" first_component="${repository_with_registry%%/*}" if [[ "$repository_with_registry" == */* ]]; then rest="${repository_with_registry#*/}" else rest="$repository_with_registry" fi if [[ "$first_component" == *.* || "$first_component" == *:* || "$first_component" == "localhost" ]]; then image_repository="$rest" else image_repository="$repository_with_registry" fi [[ -n "$image_repository" ]] || backup_die "image repository could not be derived from DOCKER_IMAGE_REF: $ref" [[ "$image_repository" =~ ^[A-Za-z0-9._/-]+$ ]] || backup_die "image repository contains unsupported characters: $image_repository" [[ "$image_tag" =~ ^[A-Za-z0-9._-]+$ ]] || backup_die "image tag contains unsupported characters: $image_tag" } derive_repository_and_tag "$image_ref" remote_path="${image_root_dir}/${image_repository}/${image_tag}" artifact_dir="${archive_root}/${image_repository}/${image_tag}" mkdir -p "$artifact_dir" tar_file="$artifact_dir/image.tar" archive_file="$artifact_dir/image.tar.zst" checksum_file="$artifact_dir/image.tar.zst.sha256" manifest_file="$artifact_dir/manifest.json" upload_report_file="$artifact_dir/works-upload.json" rm -f "$tar_file" "$archive_file" "$checksum_file" "$manifest_file" "$upload_report_file" if [[ -n "$commit_container" ]]; then backup_log "Committing container $commit_container to $image_ref" docker commit "$commit_container" "$image_ref" >/dev/null fi backup_log "Saving Docker image $image_ref" docker save -o "$tar_file" "$image_ref" backup_log "Compressing image archive with zstd" zstd -f -19 -o "$archive_file" "$tar_file" >/dev/null rm -f "$tar_file" archive_sha256="$(sha256sum "$archive_file" | awk '{print $1}')" printf '%s %s\n' "$archive_sha256" "$(basename "$archive_file")" >"$checksum_file" image_id="$(docker image inspect "$image_ref" --format '{{.Id}}' 2>/dev/null || printf 'unknown')" archive_size="$(stat -c '%s' "$archive_file")" git_commit="$(backup_git_commit "$repo_root")" jq -n \ --arg createdAt "$(backup_utc_now)" \ --arg imageRef "$image_ref" \ --arg repository "$image_repository" \ --arg tag "$image_tag" \ --arg sourceContainer "$commit_container" \ --arg imageId "$image_id" \ --arg gitCommit "$git_commit" \ --arg remotePath "$remote_path" \ --arg archiveFile "$(basename "$archive_file")" \ --arg archiveSha256 "$archive_sha256" \ --argjson archiveSize "$archive_size" \ '{ schema_version: 1, format: "docker-save-zstd", created_at: $createdAt, image_ref: $imageRef, repository: $repository, tag: $tag, source_container: $sourceContainer, docker_image_id: $imageId, git_commit: $gitCommit, remote_path: $remotePath, restore_command: ("zstd -d -c " + $archiveFile + " | docker load"), archive: { file_name: $archiveFile, size_bytes: $archiveSize, sha256: $archiveSha256 } }' >"$manifest_file" upload_files=("$archive_file" "$checksum_file" "$manifest_file") if [[ "$dry_run" == "true" ]]; then jq -n \ --arg createdAt "$(backup_utc_now)" \ --arg status "planned" \ --arg remotePath "$remote_path" \ --arg artifactDir "$artifact_dir" \ --argjson files "$(printf '%s\n' "${upload_files[@]}" | jq -R '{file_path:., file_name:(. | split("/")[-1]), status:"planned"}' | jq -s '.')" \ '{ created_at: $createdAt, status: $status, remote_path: $remotePath, artifact_dir: $artifactDir, files: $files }' >"$upload_report_file" backup_log "Dry run: packaged image artifacts at $artifact_dir" backup_log "Dry run: would upload to WORKS Drive path $remote_path" exit 0 fi backup_require_command "$curl_bin" access_token="$(resolve_access_token)" backup_log "Resolving WORKS Drive folder path: $remote_path" target_folder_id="$(ensure_folder_path "$access_token" "$remote_path")" upload_endpoint="$(resolve_target_upload_endpoint "$target_folder_id")" uploaded_items="[]" for file_path in "${upload_files[@]}"; do backup_require_path "$file_path" backup_log "Creating WORKS Drive upload URL for $(basename "$file_path")" upload_url="$(create_upload_url "$access_token" "$upload_endpoint" "$file_path")" backup_log "Uploading $(basename "$file_path") to WORKS Drive" upload_response="$(upload_file_to_url "$access_token" "$upload_url" "$file_path")" upload_response_json="$(jq -c '.' <<<"${upload_response:-{}}" 2>/dev/null || printf '{}')" uploaded_items="$(jq \ --arg fileName "$(basename "$file_path")" \ --arg filePath "$file_path" \ --argjson fileSize "$(stat -c '%s' "$file_path")" \ --arg status "uploaded" \ --arg response "$upload_response_json" \ '. + [{file_name:$fileName, file_path:$filePath, file_size:$fileSize, status:$status, response:($response | fromjson? // {})}]' \ <<<"$uploaded_items")" done jq -n \ --arg createdAt "$(backup_utc_now)" \ --arg status "uploaded" \ --arg remotePath "$remote_path" \ --arg artifactDir "$artifact_dir" \ --arg target "$target" \ --arg folderId "$target_folder_id" \ --argjson files "$uploaded_items" \ '{ created_at: $createdAt, status: $status, target: $target, remote_path: $remotePath, target_folder_id: $folderId, artifact_dir: $artifactDir, files: $files }' >"$upload_report_file" backup_log "Upload complete: $upload_report_file"