#!/usr/bin/env bash set -euo pipefail usage() { cat <<'USAGE' Usage: watch_concat_sftp.sh -d WATCH_DIR -o OUTPUT_FILE -r USER@HOST:/remote/path/file [options] Options: -d WATCH_DIR Directory to watch for incoming files. -o OUTPUT_FILE Local aggregate file to append incoming files into. -r REMOTE_TARGET SFTP target in the form user@host:/remote/path/file. -i SECONDS Upload interval. Default: 1800. -a SECONDS Minimum file age before processing. Default: 10. -p PATTERN File name pattern to include. Default: *. -s STATE_DIR State directory. Default: OUTPUT_FILE.state. -h Show this help. Environment: SFTP_BATCH_OPTS Extra options passed to sftp, for example: -i ~/.ssh/id_ed25519 -P 2222 Example: ./watch_concat_sftp.sh \ -d /var/drop/incoming \ -o /var/drop/combined/combined.txt \ -r ingest@example.com:/uploads/combined.txt USAGE } WATCH_DIR="" OUTPUT_FILE="" REMOTE_TARGET="" UPLOAD_INTERVAL=1800 MIN_AGE_SECONDS=10 INCLUDE_PATTERN="*" STATE_DIR="" while getopts ":d:o:r:i:a:p:s:h" opt; do case "$opt" in d) WATCH_DIR=$OPTARG ;; o) OUTPUT_FILE=$OPTARG ;; r) REMOTE_TARGET=$OPTARG ;; i) UPLOAD_INTERVAL=$OPTARG ;; a) MIN_AGE_SECONDS=$OPTARG ;; p) INCLUDE_PATTERN=$OPTARG ;; s) STATE_DIR=$OPTARG ;; h) usage; exit 0 ;; :) echo "Missing argument for -$OPTARG" >&2; usage >&2; exit 2 ;; \?) echo "Unknown option: -$OPTARG" >&2; usage >&2; exit 2 ;; esac done if [[ -z "$WATCH_DIR" || -z "$OUTPUT_FILE" || -z "$REMOTE_TARGET" ]]; then usage >&2 exit 2 fi if [[ ! "$REMOTE_TARGET" =~ ^[^@:]+@[^:]+:.+ ]]; then echo "REMOTE_TARGET must look like user@host:/remote/path/file" >&2 exit 2 fi if ! [[ "$UPLOAD_INTERVAL" =~ ^[0-9]+$ && "$UPLOAD_INTERVAL" -gt 0 ]]; then echo "Upload interval must be a positive integer." >&2 exit 2 fi if ! [[ "$MIN_AGE_SECONDS" =~ ^[0-9]+$ ]]; then echo "Minimum file age must be a non-negative integer." >&2 exit 2 fi mkdir -p "$WATCH_DIR" mkdir -p "$(dirname "$OUTPUT_FILE")" abs_dir() { local dir=$1 local old_pwd old_pwd=$(pwd) cd "$dir" pwd -P cd "$old_pwd" } abs_file() { local file=$1 local dir local base dir=$(dirname "$file") base=$(basename "$file") printf '%s/%s\n' "$(abs_dir "$dir")" "$base" } WATCH_DIR=$(abs_dir "$WATCH_DIR") OUTPUT_FILE=$(abs_file "$OUTPUT_FILE") if [[ -z "$STATE_DIR" ]]; then STATE_DIR="${OUTPUT_FILE}.state" fi mkdir -p "$STATE_DIR" STATE_DIR=$(abs_dir "$STATE_DIR") PROCESSED_LIST="$STATE_DIR/processed-files" LOCK_FILE="$STATE_DIR/lock" LAST_UPLOAD_FILE="$STATE_DIR/last-upload" SFTP_BATCH_FILE="$STATE_DIR/sftp-batch" if ! command -v sftp >/dev/null 2>&1; then echo "sftp is required but was not found on PATH." >&2 exit 1 fi touch "$PROCESSED_LIST" touch "$OUTPUT_FILE" log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" } remote_user_host=${REMOTE_TARGET%%:*} remote_path=${REMOTE_TARGET#*:} remote_tmp_path="${remote_path}.part" mark_processed() { printf '%s\n' "$1" >> "$PROCESSED_LIST" } already_processed() { grep -Fxq -- "$1" "$PROCESSED_LIST" } file_age_seconds() { local path=$1 local now local modified now=$(date +%s) if modified=$(stat -c %Y "$path" 2>/dev/null); then : else modified=$(stat -f %m "$path") fi printf '%s\n' "$((now - modified))" } append_ready_files() { local path local age local appended=0 while IFS= read -r -d '' path; do [[ "$path" == "$OUTPUT_FILE" ]] && continue [[ "$path" == "$STATE_DIR/"* ]] && continue already_processed "$path" && continue age=$(file_age_seconds "$path") if [[ "$age" -lt "$MIN_AGE_SECONDS" ]]; then continue fi log "Appending $path" cat "$path" >> "$OUTPUT_FILE" printf '\n' >> "$OUTPUT_FILE" mark_processed "$path" appended=$((appended + 1)) done < <(find "$WATCH_DIR" -type f -name "$INCLUDE_PATTERN" -print0) if [[ "$appended" -gt 0 ]]; then log "Appended $appended file(s) into $OUTPUT_FILE" fi } upload_output() { if [[ ! -s "$OUTPUT_FILE" ]]; then log "Skipping upload; $OUTPUT_FILE is empty." return fi cat > "$SFTP_BATCH_FILE" < "$LAST_UPLOAD_FILE" log "Upload complete." } should_upload() { local now local last_upload=0 now=$(date +%s) if [[ -s "$LAST_UPLOAD_FILE" ]]; then last_upload=$(<"$LAST_UPLOAD_FILE") fi [[ "$((now - last_upload))" -ge "$UPLOAD_INTERVAL" ]] } main_loop() { log "Watching $WATCH_DIR" log "Appending into $OUTPUT_FILE" log "Uploading to $REMOTE_TARGET every $UPLOAD_INTERVAL seconds" while true; do if command -v flock >/dev/null 2>&1; then ( flock -n 9 || exit 0 append_ready_files if should_upload; then upload_output fi ) 9>"$LOCK_FILE" else append_ready_files if should_upload; then upload_output fi fi sleep 5 done } main_loop