diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..df6c584 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +# Set update schedule for GitHub Actions +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..1d2fe6e --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,72 @@ +name: Docker + +on: + release: + types: [published] + +env: + REGISTRY_GITHUB: ghcr.io + REGISTRY_DOCKER: docker.io + IMAGE_NAME_GITHUB: ${{ github.repository }} + IMAGE_NAME_DOCKER: ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_APPNAME }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY_GITHUB }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY_DOCKER }} + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY_DOCKER }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + # Docker Hub Image + ${{ env.REGISTRY_GITHUB }}/${{ env.IMAGE_NAME_GITHUB }} + # GitHub Image + ${{ env.REGISTRY_DOCKER }}/${{ env.IMAGE_NAME_DOCKER }} + tags: | + type=semver,pattern={{raw}} + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=tag + type=ref,event=pr + type=sha,enable=false + + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f97e58f --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +### macOS ### +*.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3ec6ece --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +FROM arm64v8/ubuntu:24.04 + +LABEL maintainer="Monsieur Borges" +LABEL version="1.0.0" +LABEL date="2024-07-15" + +# ADD Installation Scripts +########################################################### +ADD ./scripts/pishrink.sh /usr/local/bin/pishrink + +# Improve Terminal (make sure things are pretty) +########################################################### +ENV SHELL=/bin/bash +ENV LANG=C.UTF-8 +ENV TERM=xterm-256color +ENV DEBIAN_FRONTEND=noninteractive + +# Create a new Docker Image +########################################################### +WORKDIR /workdir + +RUN apt-get update && \ + # Install dependencies + #---------------------------------------------------------- + apt-get --yes --quiet dist-upgrade\ + && apt-get install --yes --quiet --no-install-recommends \ + wget \ + parted \ + gzip \ + pigz \ + xz-utils \ + udev \ + # Install dependencies + #---------------------------------------------------------- + && chmod +x /usr/local/bin/pishrink \ + # Improve Terminal + #---------------------------------------------------------- + && cp /etc/skel/.bashrc ~/ \ + && sed -ri 's/^#force_color_prompt=yes/force_color_prompt=yes/' ~/.bashrc \ + # Cleanup + #---------------------------------------------------------- + && rm -rf ~/.cache/* \ + && rm -rf /var/lib/apt/lists/* + +# Build and Run +########################################################### +# docker build --rm --tag "monsieurborges/pishrink:latest" . diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..67b45fe --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) Monsieur Borges. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8f0b9e --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# PiShrink dockerized + +**PiShrink** automatically shrink a Raspberry Pi image in order to reduce the final image size. + +It's great for saving disk space or sharing your Pi image on the Internet. + +This project is a dockerized version of the [PiShrink bash script](https://github.com/Drewsif/PiShrink) by Drew Bonasera. + +![Release][release-install-shield] ![Docker][docker-shield] [![License][license-shield]](LICENSE.md) + +## Usage + +1. Make a copy of a Raspberry Pi SD card that you want to shrink ([see instructions here](https://github.com/monsieurborges/raspberry-pi/blob/master/setup/clone-sd-card.md)). + +2. Using the Terminal, access the directory containing the Raspberry Pi image: + + ```bash + cd ~/Directory-with-RPi-image + ``` + +3. Run PiShrink dockerized: + + ```bash + docker run --privileged=true --rm \ + --volume $(pwd):/workdir \ + monsieurborges/pishrink \ + pishrink -Zv IMAGE.img NEW-IMAGE.img + ``` + +## PiShrink options + +```shell +pishrink [-adhrsvzZ] IMAGE.img NEW-IMAGE.img + +-s Do not expand filesystem when image is booted the first time +-v Enables more verbose output +-r Use advanced filesystem repair option if the normal one fails +-z Compress image after shrinking with gzip +-Z Compress image after shrinking with xz +-a Compress image in parallel using multiple cores +-d Write debug messages in a debug log file +``` + +If you specify the `NEW-IMAGE.img` parameter, the script will make a copy of `IMAGE.img` and work off that. You will need enough space to make a full copy of the image to use that option. + +Check out [PiShrink GitHub](https://github.com/Drewsif/PiShrink) for more details. + +## Docker Hub + +* [monsieurborges/pishrink](https://hub.docker.com/r/monsieurborges/pishrink) + +## Author + +* [Monsieur Borges](https://github.com/monsieurborges) + +## License + +The source code is licensed under the [MIT license](LICENSE.md). + +The content of this project itself is licensed under the [Creative Commons Attribution 4.0 International](https://creativecommons.org/licenses/by/4.0). + +[release-install-shield]: https://img.shields.io/badge/Release-15--Jul--2024-blue +[license-shield]: https://img.shields.io/github/license/monsieurborges/pishrink-docker +[docker-shield]: https://github.com/monsieurborges/pishrink-docker/actions/workflows/docker-publish.yml/badge.svg diff --git a/scripts/pishrink.sh b/scripts/pishrink.sh new file mode 100644 index 0000000..f16af49 --- /dev/null +++ b/scripts/pishrink.sh @@ -0,0 +1,429 @@ +#!/bin/bash + +version="v0.1.4" + +CURRENT_DIR="$(pwd)" +SCRIPTNAME="${0##*/}" +MYNAME="${SCRIPTNAME%.*}" +LOGFILE="${CURRENT_DIR}/${SCRIPTNAME%.*}.log" +REQUIRED_TOOLS="parted losetup tune2fs md5sum e2fsck resize2fs" +ZIPTOOLS=("gzip xz") +declare -A ZIP_PARALLEL_TOOL=( [gzip]="pigz" [xz]="xz" ) # parallel zip tool to use in parallel mode +declare -A ZIP_PARALLEL_OPTIONS=( [gzip]="-f9" [xz]="-T0" ) # options for zip tools in parallel mode +declare -A ZIPEXTENSIONS=( [gzip]="gz" [xz]="xz" ) # extensions of zipped files + +function info() { + echo "$SCRIPTNAME: $1 ..." +} + +function error() { + echo -n "$SCRIPTNAME: ERROR occurred in line $1: " + shift + echo "$@" +} + +function cleanup() { + if losetup "$loopback" &>/dev/null; then + losetup -d "$loopback" + fi + if [ "$debug" = true ]; then + local old_owner=$(stat -c %u:%g "$src") + chown "$old_owner" "$LOGFILE" + fi + +} + +function logVariables() { + if [ "$debug" = true ]; then + echo "Line $1" >> "$LOGFILE" + shift + local v var + for var in "$@"; do + eval "v=\$$var" + echo "$var: $v" >> "$LOGFILE" + done + fi +} + +function checkFilesystem() { + info "Checking filesystem" + e2fsck -pf "$loopback" + (( $? < 4 )) && return + + info "Filesystem error detected!" + + info "Trying to recover corrupted filesystem" + e2fsck -y "$loopback" + (( $? < 4 )) && return + +if [[ $repair == true ]]; then + info "Trying to recover corrupted filesystem - Phase 2" + e2fsck -fy -b 32768 "$loopback" + (( $? < 4 )) && return +fi + error $LINENO "Filesystem recoveries failed. Giving up..." + exit 9 + +} + +function set_autoexpand() { + #Make pi expand rootfs on next boot + mountdir=$(mktemp -d) + partprobe "$loopback" + sleep 3 + umount "$loopback" > /dev/null 2>&1 + mount "$loopback" "$mountdir" -o rw + if (( $? != 0 )); then + info "Unable to mount loopback, autoexpand will not be enabled" + return + fi + + if [ ! -d "$mountdir/etc" ]; then + info "/etc not found, autoexpand will not be enabled" + umount "$mountdir" + return + fi + + if [[ ! -f "$mountdir/etc/rc.local" ]]; then + info "An existing /etc/rc.local was not found, autoexpand may fail..." + fi + + if [[ -f "$mountdir/etc/rc.local" ]] && [[ "$(md5sum "$mountdir/etc/rc.local" | cut -d ' ' -f 1)" != "5c286b336c0606ed8e6f87708f7802eb" ]]; then + echo "Creating new /etc/rc.local" + if [ -f "$mountdir/etc/rc.local" ]; then + mv "$mountdir/etc/rc.local" "$mountdir/etc/rc.local.bak" + fi + + #####Do not touch the following lines##### +cat <<\EOF1 > "$mountdir/etc/rc.local" +#!/bin/bash +do_expand_rootfs() { + ROOT_PART=$(mount | sed -n 's|^/dev/\(.*\) on / .*|\1|p') + + PART_NUM=${ROOT_PART#mmcblk0p} + if [ "$PART_NUM" = "$ROOT_PART" ]; then + echo "$ROOT_PART is not an SD card. Don't know how to expand" + return 0 + fi + + # Get the starting offset of the root partition + PART_START=$(parted /dev/mmcblk0 -ms unit s p | grep "^${PART_NUM}" | cut -f 2 -d: | sed 's/[^0-9]//g') + [ "$PART_START" ] || return 1 + # Return value will likely be error for fdisk as it fails to reload the + # partition table because the root fs is mounted + fdisk /dev/mmcblk0 < /etc/rc.local && +#!/bin/sh +echo "Expanding /dev/$ROOT_PART" +resize2fs /dev/$ROOT_PART +rm -f /etc/rc.local; cp -fp /etc/rc.local.bak /etc/rc.local && /etc/rc.local + +EOF +reboot +exit +} +raspi_config_expand() { +/usr/bin/env raspi-config --expand-rootfs +if [[ $? != 0 ]]; then + return -1 +else + rm -f /etc/rc.local; cp -fp /etc/rc.local.bak /etc/rc.local && /etc/rc.local + reboot + exit +fi +} +raspi_config_expand +echo "WARNING: Using backup expand..." +sleep 5 +do_expand_rootfs +echo "ERROR: Expanding failed..." +sleep 5 +if [[ -f /etc/rc.local.bak ]]; then + cp -fp /etc/rc.local.bak /etc/rc.local + /etc/rc.local +fi +exit 0 +EOF1 + #####End no touch zone##### + chmod +x "$mountdir/etc/rc.local" + fi + umount "$mountdir" +} + +help() { + local help + read -r -d '' help << EOM +Usage: $0 [-adhrsvzZ] imagefile.img [newimagefile.img] + + -s Don't expand filesystem when image is booted the first time + -v Be verbose + -r Use advanced filesystem repair option if the normal one fails + -z Compress image after shrinking with gzip + -Z Compress image after shrinking with xz + -a Compress image in parallel using multiple cores + -d Write debug messages in a debug log file +EOM + echo "$help" + exit 1 +} + +should_skip_autoexpand=false +debug=false +repair=false +parallel=false +verbose=false +ziptool="" + +while getopts ":adhrsvzZ" opt; do + case "${opt}" in + a) parallel=true;; + d) debug=true;; + h) help;; + r) repair=true;; + s) should_skip_autoexpand=true ;; + v) verbose=true;; + z) ziptool="gzip";; + Z) ziptool="xz";; + *) help;; + esac +done +shift $((OPTIND-1)) + +if [ "$debug" = true ]; then + info "Creating log file $LOGFILE" + rm "$LOGFILE" &>/dev/null + exec 1> >(stdbuf -i0 -o0 -e0 tee -a "$LOGFILE" >&1) + exec 2> >(stdbuf -i0 -o0 -e0 tee -a "$LOGFILE" >&2) +fi + +echo "${0##*/} $version" + +#Args +src="$1" +img="$1" + +#Usage checks +if [[ -z "$img" ]]; then + help +fi + +if [[ ! -f "$img" ]]; then + error $LINENO "$img is not a file..." + exit 2 +fi +if (( EUID != 0 )); then + error $LINENO "You need to be running as root." + exit 3 +fi + +# set locale to POSIX(English) temporarily +# these locale settings only affect the script and its sub processes + +export LANGUAGE=POSIX +export LC_ALL=POSIX +export LANG=POSIX + + +# check selected compression tool is supported and installed +if [[ -n $ziptool ]]; then + if [[ ! " ${ZIPTOOLS[@]} " =~ $ziptool ]]; then + error $LINENO "$ziptool is an unsupported ziptool." + exit 17 + else + if [[ $parallel == true && $ziptool == "gzip" ]]; then + REQUIRED_TOOLS="$REQUIRED_TOOLS pigz" + else + REQUIRED_TOOLS="$REQUIRED_TOOLS $ziptool" + fi + fi +fi + +#Check that what we need is installed +for command in $REQUIRED_TOOLS; do + command -v $command >/dev/null 2>&1 + if (( $? != 0 )); then + error $LINENO "$command is not installed." + exit 4 + fi +done + +#Copy to new file if requested +if [ -n "$2" ]; then + f="$2" + if [[ -n $ziptool && "${f##*.}" == "${ZIPEXTENSIONS[$ziptool]}" ]]; then # remove zip extension if zip requested because zip tool will complain about extension + f="${f%.*}" + fi + info "Copying $1 to $f..." + cp --reflink=auto --sparse=always "$1" "$f" + if (( $? != 0 )); then + error $LINENO "Could not copy file..." + exit 5 + fi + old_owner=$(stat -c %u:%g "$1") + chown "$old_owner" "$f" + img="$f" +fi + +# cleanup at script exit +trap cleanup EXIT + +#Gather info +info "Gathering data" +beforesize="$(ls -lh "$img" | cut -d ' ' -f 5)" +parted_output="$(parted -ms "$img" unit B print)" +rc=$? +if (( $rc )); then + error $LINENO "parted failed with rc $rc" + info "Possibly invalid image. Run 'parted $img unit B print' manually to investigate" + exit 6 +fi +partnum="$(echo "$parted_output" | tail -n 1 | cut -d ':' -f 1)" +partstart="$(echo "$parted_output" | tail -n 1 | cut -d ':' -f 2 | tr -d 'B')" +if [ -z "$(parted -s "$img" unit B print | grep "$partstart" | grep logical)" ]; then + parttype="primary" +else + parttype="logical" +fi +loopback="$(losetup -f --show -o "$partstart" "$img")" +tune2fs_output="$(tune2fs -l "$loopback")" +rc=$? +if (( $rc )); then + echo "$tune2fs_output" + error $LINENO "tune2fs failed. Unable to shrink this type of image" + exit 7 +fi + +currentsize="$(echo "$tune2fs_output" | grep '^Block count:' | tr -d ' ' | cut -d ':' -f 2)" +blocksize="$(echo "$tune2fs_output" | grep '^Block size:' | tr -d ' ' | cut -d ':' -f 2)" + +logVariables $LINENO beforesize parted_output partnum partstart parttype tune2fs_output currentsize blocksize + +#Check if we should make pi expand rootfs on next boot +if [ "$parttype" == "logical" ]; then + echo "WARNING: PiShrink does not yet support autoexpanding of this type of image" +elif [ "$should_skip_autoexpand" = false ]; then + set_autoexpand +else + echo "Skipping autoexpanding process..." +fi + +#Make sure filesystem is ok +checkFilesystem + +if ! minsize=$(resize2fs -P "$loopback"); then + rc=$? + error $LINENO "resize2fs failed with rc $rc" + exit 10 +fi +minsize=$(cut -d ':' -f 2 <<< "$minsize" | tr -d ' ') +logVariables $LINENO currentsize minsize +if [[ $currentsize -eq $minsize ]]; then + error $LINENO "Image already shrunk to smallest size" + exit 11 +fi + +#Add some free space to the end of the filesystem +extra_space=$(($currentsize - $minsize)) +logVariables $LINENO extra_space +for space in 5000 1000 100; do + if [[ $extra_space -gt $space ]]; then + minsize=$(($minsize + $space)) + break + fi +done +logVariables $LINENO minsize + +#Shrink filesystem +info "Shrinking filesystem" +resize2fs -p "$loopback" $minsize +rc=$? +if (( $rc )); then + error $LINENO "resize2fs failed with rc $rc" + mount "$loopback" "$mountdir" + mv "$mountdir/etc/rc.local.bak" "$mountdir/etc/rc.local" + umount "$mountdir" + losetup -d "$loopback" + exit 12 +fi +sleep 1 + +#Shrink partition +partnewsize=$(($minsize * $blocksize)) +newpartend=$(($partstart + $partnewsize)) +logVariables $LINENO partnewsize newpartend +parted -s -a minimal "$img" rm "$partnum" +rc=$? +if (( $rc )); then + error $LINENO "parted failed with rc $rc" + exit 13 +fi + +parted -s "$img" unit B mkpart "$parttype" "$partstart" "$newpartend" +rc=$? +if (( $rc )); then + error $LINENO "parted failed with rc $rc" + exit 14 +fi + +#Truncate the file +info "Shrinking image" +endresult=$(parted -ms "$img" unit B print free) +rc=$? +if (( $rc )); then + error $LINENO "parted failed with rc $rc" + exit 15 +fi + +endresult=$(tail -1 <<< "$endresult" | cut -d ':' -f 2 | tr -d 'B') +logVariables $LINENO endresult +truncate -s "$endresult" "$img" +rc=$? +if (( $rc )); then + error $LINENO "trunate failed with rc $rc" + exit 16 +fi + +# handle compression +if [[ -n $ziptool ]]; then + options="" + envVarname="${MYNAME^^}_${ziptool^^}" # PISHRINK_GZIP or PISHRINK_XZ environment variables allow to override all options for gzip or xz + [[ $parallel == true ]] && options="${ZIP_PARALLEL_OPTIONS[$ziptool]}" + [[ -v $envVarname ]] && options="${!envVarname}" # if environment variable defined use these options + [[ $verbose == true ]] && options="$options -v" # add verbose flag if requested + + if [[ $parallel == true ]]; then + parallel_tool="${ZIP_PARALLEL_TOOL[$ziptool]}" + info "Using $parallel_tool on the shrunk image" + if ! $parallel_tool ${options} "$img"; then + rc=$? + error $LINENO "$parallel_tool failed with rc $rc" + exit 18 + fi + + else # sequential + info "Using $ziptool on the shrunk image" + if ! $ziptool ${options} "$img"; then + rc=$? + error $LINENO "$ziptool failed with rc $rc" + exit 19 + fi + fi + img=$img.${ZIPEXTENSIONS[$ziptool]} +fi + +aftersize=$(ls -lh "$img" | cut -d ' ' -f 5) +logVariables $LINENO aftersize + +info "Shrunk $img from $beforesize to $aftersize"