#!/bin/bash # This script can be used to start, stop, create, and delete containers. # It is what confd use, with the Finit container@.conf template, to set # up, run, and delete containers. # # NOTE: when creating/deleting containers, remember 'initctl reload' to # activate the changes! In confd this is already handled. # # TODO: this script should be refactored to take a .cfg file instead for # each container. For details, see this pull request discussion: # https://github.com/kernelkit/infix/pull/1151#discussion_r2444851292 # # shellcheck disable=SC1001 CLEANUP=/var/lib/containers/cleanup DOWNLOADS=/var/lib/containers/oci BUILTIN=/lib/oci BASEDIR=/var/tmp container=$0 checksum="" extracted= timeout=30 # NOTE: matched with container@.conf dir="" all="" env="" port="" force= # Variable shared across subshells export meta_sha="" dbg() { logger -I $PPID -t container -p local1.debug -- "$*" } log() { logger -I $PPID -t container -p local1.notice -- "$*" } err() { rc=$1; shift logger -I $PPID -t container -p local1.err -- "Error: $*" if [ -n "$extracted" ] && [ -n "$tmpdir" ]; then if [ -d "$tmpdir" ]; then log "Cleaning up temporary directory $tmpdir" rm -rf "$tmpdir" fi fi [ "$rc" -eq 0 ] || exit "$rc" } pidfn() { echo "/run/containers/${1}.pid" } calc_sha() { sha256sum "$1" 2>/dev/null | awk '{print $1}' } # Check image transport, return 0 if remote, 1 if local is_remote() { image=$(awk '/^# meta-image:/ {print $3}' "$1" 2>/dev/null || echo "") echo "$image" case "$image" in oci\:* | oci-archive\:* | docker-archive\:* | docker-daemon\:* | \ dir\:* | containers-storage\:* | ostree\:* | sif\:* | /*) return 1 ;; *) return 0 ;; esac } # Check loaded image in container store vs archive, as well as verifying # the container instance vs. the configuration. # # This is an optimization to cut down startup times. is_uptodate() { img_sha=$(awk '/^# meta-image-sha256:/ {print $3}' "$script" 2>/dev/null) if [ -n "$img_sha" ]; then # Local image optimization: check if archive SHA matches stored sidecar file img=$(awk '/^# meta-image:/ {print $3}' "$script" 2>/dev/null) if [ -n "$img" ]; then case "$img" in docker-archive\:*) archive_path="${img#docker-archive:}" ;; oci-archive\:*) archive_path="${img#oci-archive:}" ;; *) archive_path="$img" ;; esac # Handle relative paths - check BUILTIN and DOWNLOADS if [ ! -f "$archive_path" ]; then if [ -f "$DOWNLOADS/$(basename "$archive_path")" ]; then archive_path="$DOWNLOADS/$(basename "$archive_path")" elif [ -f "$BUILTIN/$(basename "$archive_path")" ]; then archive_path="$BUILTIN/$(basename "$archive_path")" fi fi # Check if the archive exists and compare SHA with sidecar file if [ -f "$archive_path" ]; then archive_basename=$(basename "$archive_path") sha_file="$DOWNLOADS/${archive_basename}.sha256" if [ -f "$sha_file" ]; then stored_sha=$(cat "$sha_file" 2>/dev/null) current_sha=$(calc_sha "$archive_path") # If SHA matches, check container instance if [ "$stored_sha" = "$current_sha" ]; then if podman container exists "$name"; then config_sha=$(calc_sha "$script") container_sha=$(podman inspect "$name" --format '{{index .Config.Labels "config-sha256"}}' 2>/dev/null) container_img_sha=$(podman inspect "$name" --format '{{index .Config.Labels "meta-image-sha256"}}' 2>/dev/null) if [ "$container_img_sha" = "$img_sha" ] && [ "$container_sha" = "$config_sha" ]; then # Unmodified local image and configuration return 0 fi fi else # Archive changed (e.g., rootfs upgrade) - need to reload dbg "Archive SHA changed, will reload image and recreate container" fi fi fi fi else # Remote image optimization: check config-sha256 only if podman container exists "$name"; then config_sha=$(calc_sha "$script") container_sha=$(podman inspect "$name" --format '{{index .Config.Labels "config-sha256"}}' 2>/dev/null) if [ "$container_sha" = "$config_sha" ]; then # Unmodified local image and configuration return 0 fi fi fi return 1 } check() { file=$1 if [ -z "$checksum" ]; then log "no checksum to verify $file against, continuing." return 0 fi if echo "${checksum} ${file}" | "$cmdsum" -c -s; then log "$file checksum verified OK." return 0 fi got=$("$cmdsum" "${file}" | awk '{print $1}') log "$file checksum mismatch, got $got, expected $checksum, removing file." rm -f "$file" return 1 } # Fetch an OCI image over ftp/http/https. Use wget for FTP, which curl # empirically does not work well with. Log progress+ & error to syslog. fetch() { url=$1 file=$(basename "$url") dst="$DOWNLOADS/$file" cd "$DOWNLOADS" || return if [ -f "$file" ]; then if [ -n "$force" ]; then log "Force flag set, removing cached $file and re-downloading." rm -f "$file" else log "$file already available." if check "$file"; then echo "$dst" return 0 fi fi fi log "Fetching $url" if echo "$url" | grep -qE "^ftp://"; then cmd="wget -q $url" elif echo "$url" | grep -qE "^https?://"; then cmd="curl $creds -sSL --fail -o \"$file\" $url" else log "Unsupported URL scheme: $url" return 1 fi if out=$(eval "$cmd" 2>&1); then log "$file downloaded successfully." if check "$file"; then echo "$dst" return 0 fi fi # log error message from backend while IFS= read -r line; do log "$line" done <1 index.json, this function does not handle them. load_archive() { uri=$1 tag=$2 tmp=$3 img=$(basename "$uri") # Supported transports for load and create case "$uri" in oci:*) # Unpacked OCI image file=${uri#oci:} ;; oci-archive:*) # Packed OCI image, .tar or .tar.gz format file=${uri#oci-archive:} ;; ftp://* | http://* | https://*) if ! file=$(fetch "$uri"); then return 1 fi ;; *) # docker://*, docker-archive:*, or URL # Skip existence check if force flag is set (e.g., for upgrade command) if [ -z "$force" ] && podman image exists "$img"; then echo "$img" return 0 fi # XXX: use --retry=0 with Podman 5.0 or later. if ! id=$(podman pull --quiet "$uri"); then log "Failed pulling $uri" return 1 fi # Echo image tag to caller podman images --filter id="$id" --format "{{.Repository}}:{{.Tag}}" return 0 ;; esac if [ ! -f "$file" ]; then if [ -f "$DOWNLOADS/$file" ]; then file="$DOWNLOADS/$file" elif [ -f "$BUILTIN/$file" ]; then file="$BUILTIN/$file" else err 1 "cannot find OCI archive $file in URI $uri" fi fi file=$(realpath "$file") if [ -d "$file" ]; then index=$(find "$file" -name index.json) if [ -z "$index" ]; then err 1 "cannot find index.json in OCI image $file" fi else # Extract files in a temporary directory, because most OCI # archives are flat/bare, all files in the root w/o a dir/ tmpdir=$(mktemp -d -p "$BASEDIR") || err 1 "failed creating temporary directory" cd "$tmpdir" || err 1 "failed cd to temporary directory $tmpdir" index="$tmpdir/$(tar tf "$file" |grep index.json)" if [ -z "$index" ]; then err 1 "invalid OCI archive, cannot find index.json in $file" fi [ -n "$quiet" ] || log "Extracting OCI archive $file ..." tar xf "$file" || err 1 "failed unpacking $file in $tmpdir" extracted=true cd - >/dev/null || err 0 "failed cd -" fi dir=$(dirname "$index") # Handle flat tarballs without a sub-directory, because # the $dir name is used as fallback when retagging below. if [ -n "$extracted" ] && [ "$dir" = "$tmpdir" ]; then parent=$(dirname "$dir") dirnam=$(echo "$img" | sed 's/\(.*\)\.tar.*/\1/') tmpdir="${parent}/${dirnam}" mv "$dir" "$tmpdir" dir="$tmpdir" fi if basename "$dir" | grep -q ":"; then if [ -z "$tag" ]; then tag=$(basename "$dir") fi sanitized_dir=$(echo "$dir" | cut -d':' -f1) mv "$dir" "$sanitized_dir" || err 1 "failed renaming $dir to $sanitized_dir" dir="$sanitized_dir" fi [ -n "$quiet" ] || log "Loading OCI image $dir ..." output=$(nice podman load -qi "$dir") # Extract image ID from podman load output: # "Loaded image: sha256:cd9d0aaf81be..." if echo "$output" | grep -q "sha256:"; then img_id="${output##*sha256:}" else # Fallback to directory name if no SHA found img_id="$dir" fi # On podman < 4.7.0 we had to retag images from default $dir:latest # From >= 4.7.0 we always tag since loads come in as : if [ -z "$tag" ]; then tag=$(basename "$dir") fi # Repo names must be lowercase, and only '[a-z0-9._/-]+' and ':tag' tag=$(printf "%s" "$tag" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9._/:-' '-') [ -n "$quiet" ] || log "Tagging loaded image $img_id as $tag" if ! podman tag "$img_id" "$tag"; then err 1 "failed tagging image as $tag" fi # Save archive SHA256 to sidecar file to enable optimization # This applies to both local archives and downloaded remote archives if [ -f "$file" ]; then img_sha256=$(calc_sha "$file") archive_basename=$(basename "$file") sha_file="$DOWNLOADS/${archive_basename}.sha256" mkdir -p "$DOWNLOADS" echo "$img_sha256" > "$sha_file" if [ -n "$tmp" ]; then echo "$img_sha256" > "$tmp" fi log "Saved archive checksum to $sha_file" fi # Clean up after ourselves if [ -n "$extracted" ]; then log "Cleaning up extracted $dir" rm -rf "$tmpdir" fi echo "$tag" } running() { status=$(podman inspect -f '{{.State.Status}}' "$1" 2>/dev/null) [ "$status" = "running" ] && return 0 return 1 } # shellcheck disable=SC2086 create() { name=$1 image=$2 shift 2 if [ -z "$name" ] || [ -z "$image" ]; then echo "Usage:" echo " container create NAME IMAGE" exit 1 fi # Unpack and load docker-archive/oci/oci-archive, returning image # name, or return docker:// URL for download. sha_file=$(mktemp) if ! image=$(load_archive "$image" "" "$sha_file"); then exit 1 fi if [ -s "$sha_file" ]; then img_sha=$(cat "$sha_file" 2>/dev/null || echo "") rm "$sha_file" fi if [ -z "$logging" ]; then logging="--log-driver syslog" fi # When we get here we've already fetched, or pulled, the image args="$args --read-only --replace --quiet --cgroup-parent=containers $caps" args="$args --restart=$restart --systemd=false --tz=local $privileged" args="$args $vol $mount $hostname $entrypoint $env $port $logging" pidfile=/run/container:${name}.pid [ -n "$quiet" ] || log "---------------------------------------" [ -n "$quiet" ] || log "Got name: $name image: $image" [ -n "$quiet" ] || log "Got networks: $network" if [ -n "$network" ]; then for net in $network; do args="$args --net=$net" done for srv in $dns; do args="$args --dns=$srv" done for domain in $search; do args="$args --dns-search=$domain" done else args="$args --network=none" fi # Add optimization labels for meta-image-sha256 and config checksum script="/run/containers/${name}.sh" if [ -f "$script" ]; then if [ -z "$img_sha" ]; then # Extract meta-image-sha256 from script if present img_sha=$(awk '/^# meta-image-sha256:/ {print $3}' "$script" 2>/dev/null) fi if [ -n "$img_sha" ]; then args="$args --label meta-image-sha256=$img_sha" fi # Add config checksum label args="$args --label config-sha256=$(calc_sha "$script")" fi # shellcheck disable=SC2048 log "podman create --name $name --conmon-pidfile=$pidfile $args $image $*" if nice podman create --name "$name" --conmon-pidfile="$pidfile" $args "$image" $*; then [ -n "$quiet" ] || log "Successfully created container $name from $image" [ -n "$manual" ] || start "$name" # Should already be enabled by confd (this is for manual use) initctl -bnq enable "container@${name}.conf" exit 0 fi err 1 "failed creating container $name, please check the configuration." } delete() { name=$1 if [ -z "$name" ]; then echo "Usage:" echo " container delete NAME" exit 1 fi # Should already be stopped, but if not ... container stop "$name" >/dev/null while running "$name"; do log "$name: still running, waiting for it to stop ..." _=$((timeout -= 1)) if [ $timeout -le 0 ]; then err 1 "timed out waiting for container $1 to stop before deleting it." fi sleep 1 done # NOTE: -v does not remove *named volumes* podman rm -vif "$name" >/dev/null 2>&1 [ -n "$quiet" ] || log "Container $name has been removed." } # Called by Finit cleanup:script when a container service is removed. # Processes all container instance IDs found in $CLEANUP// directory # and removes them. This handles the case where a container with the same # name but different configuration replaces an old one. # # Note: Volumes are NOT automatically removed to prevent accidental data loss. # Use 'admin-exec container prune' to clean up unused volumes manually. cleanup() { if [ -z "$name" ]; then log "cleanup: missing container name" return 1 fi cleanup_dir="$CLEANUP/$name" if [ ! -d "$cleanup_dir" ]; then log "cleanup: no cleanup directory for $name, nothing to do" return 0 fi log "Cleaning up container instances for: $name" # Remove all container instances by ID from the cleanup directory for id_file in "$cleanup_dir"/*; do [ -f "$id_file" ] || continue cid=$(basename "$id_file") # Extract image name and meta-image-sha256 from the container instance labels # These are the image in container store and the trace to an oci-archive.tar.gz img=$(podman inspect "$cid" 2>/dev/null | jq -r '.[].ImageName' 2>/dev/null || echo "") sha=$(podman inspect "$cid" --format '{{index .Config.Labels "meta-image-sha256"}}' 2>/dev/null || echo "") log "Removing container instance with ID: ${cid:0:12}..." podman rm -vif "$cid" >/dev/null 2>&1 rm -f "$id_file" # Clean up the image if it exists and is not used by other containers if [ -n "$img" ] && [ "$img" != "null" ]; then if ! podman ps -a --format "{{.Image}}" | grep -q "^${img}$"; then log "Removing unused image: $img" podman rmi "$img" 2>/dev/null || true # Also remove archive and sidecar checksum file from $DOWNLOADS for remote images # I.e., $DOWNLOADS/oci-archive.tar.gz and $DOWNLOADS/oci-archive.tar.gz.sha256 if [ -n "$sha" ] && [ -d "$DOWNLOADS" ]; then # Search for matching sidecar file containing this SHA for sha_file in "$DOWNLOADS"/*.sha256; do [ -f "$sha_file" ] || continue stored_sha=$(cat "$sha_file" 2>/dev/null || echo "") if [ "$stored_sha" = "$sha" ]; then # Found matching sidecar - derive archive filename by stripping .sha256 archive="${sha_file%.sha256}" if [ -f "$archive" ]; then # Safety check: verify archive SHA matches before removing if [ "$(calc_sha "$archive")" = "$sha" ]; then log "Removing archive: $archive" rm -f "$archive" else log "Warning: archive SHA mismatch for $archive, not removing" fi fi log "Removing sidecar file: $sha_file" rm -f "$sha_file" break fi done fi else log "Image $img still in use by other containers, not removing" fi fi done # Remove the cleanup directory for this container, and parent if empty rmdir "$cleanup_dir" 2>/dev/null rmdir "$CLEANUP" 2>/dev/null exit 0 } waitfor() { while [ ! -f "$1" ]; do _=$((timeout -= 1)) if [ $timeout -le 0 ]; then err 1 "timed out waiting for $1, aborting!" fi sleep 1; done } start() { name=$1 if running "$name"; then [ -n "$quiet" ] || echo "$name: already running." return fi initctl start container:$name # Real work is done by wrap() courtesy of finit sysv emulation } stop() { name=$1 if ! running "$name"; then [ -n "$quiet" ] || echo "$name: not running." return fi initctl stop container:$name # Real work is done by wrap() courtesy of finit sysv emulation } # When called by Finit: `initctl stop foo`, the container script # is called as `container -n $foo stop`. For the stop command # we want to bypass the default 10 second timeout. # # NOTE: containers have three phases: setup, running, and teardown. # the setup phase is this script trying to fetch the image. wrap() { name=$1 cmd=$2 args= pidfile=$(pidfn "$name") if [ "$cmd" = "stop" ]; then # The setup phase may run forever in the background trying to fetch # the image. It saves its PID in /run/containers/${name}.pid if [ -f "$pidfile" ]; then pid=$(cat "$pidfile") # Check if setup is still running ... if kill -0 "$pid" 2>/dev/null; then kill "$pid" wait "$pid" 2>/dev/null fi rm -f "$pidfile" return 0 fi # Only the 'podman stop' command takes -i and --timeout args="-i --timeout $timeout" fi # Skip "echo $name" from podman start in log # shellcheck disable=SC2086 podman "$cmd" $args "$name" >/dev/null } # Removes network $1 from all containers netwrm() { net=$1 for c in $(podman ps $all --format "{{.Names}}"); do for n in $(podman inspect "$c" |jq -r '.[].NetworkSettings.Networks | keys[]'); do if [ "$n" = "$net" ]; then podman network disconnect $force "$n" "$c" >/dev/null fi done done } # Schedule restart of (any) container using network $1 to activate network changes netrestart() { net=$1 for c in $(podman ps $all --format "{{.Names}}"); do for n in $(podman inspect "$c" |jq -r '.[].NetworkSettings.Networks | keys[]'); do if [ "$n" = "$net" ]; then initctl -nbq touch "container@$c" fi done done } atexit() { pidfile=$(pidfn "$name") log "Received signal, exiting." if [ -n "$name" ] && [ -f "$pidfile" ]; then log "$name: in setup phase, removing $pidfile ..." rm -f "$pidfile" fi exit 1 } usage() { cat <