Otomasi tidak dimulai dari jadwal, melainkan dari tugas yang akan dijadwalkan. Scheduler hanyalah mesin pengulangan: ia menjalankan apa pun yang diberikan, tepat waktu, tanpa bertanya — termasuk skrip yang setengah jalan dan merusak foldernya sendiri ketika diulang dua jam kemudian. Karena itu pada bagian ini kita menahan diri dari crontab lebih dulu, dan menulis tugas inti yang aman untuk diulang: sinkronisasi rsync dari folder sumber ke folder mirror disertai ringkasan hasil per eksekusi. Skrip inilah yang pada Bagian 2 nanti dipasang ke dua scheduler sekaligus.
Tugas yang Diotomasi: Sinkron plus Ringkasan #
Seperti proyek-proyek sebelumnya, kita bekerja untuk satu pengguna yang konkret. Budi menyimpan catatan kerja harian di folder sumber, dan membutuhkan salinan mirror yang selalu identik — mirror itulah yang dibaca oleh langkah berikutnya di luar proyek ini, misalnya backup mingguan ke disk eksternal yang hanya menyalin folder bersih. Menyalin manual setiap pagi adalah pekerjaan yang sempurna untuk otomasi: rutin, mudah, dan pelupa.
Desain tugasnya ringkas:
- Sinkron:
rsync -a --deletemenyalin isisumberkemirror— berkas baru disalin, yang berubah diperbarui, yang dihapus ikut terhapus, sehingga mirror selalu cermin sumber. - Ringkasan: setiap eksekusi mencatat satu baris ringkas — berapa berkas diperiksa, berapa dipindah, berapa dihapus — untuk dilihat manusia maupun dibaca skrip lain.
- Lokasi kerja: seluruh jalur dihitung dari letak skrip itu sendiri, sehingga tugas bisa dipanggil dari direktori kerja apa pun (cron, misalnya, memanggil dari
$HOME).
Struktur folder instalasinya seperti ini:
otomasi/
├── tugas.sh ← tugas terjadwal (ditulis pada bagian ini)
├── sumber/ ← catatan kerja Budi
│ ├── tugas.txt
│ └── laporan/
│ ├── penjualan.txt
│ └── rapat.txt
├── mirror/ ← hasil sinkronisasi (dibuat otomatis)
└── log/
└── tugas.log ← satu baris per eksekusi (dibedah di Bagian 3)Folder mirror dan log tidak perlu dibuat manual — keduanya muncul pada eksekusi pertama. Yang penting adalah pemisahan tiga peran: sumber adalah kebenaran, mirror adalah salinan, dan log adalah bukti kerjanya.
Idempoten: Jalankan Berapa Kali pun, Hasilnya Sama #
Sifat paling penting dari tugas terjadwal adalah idempoten: berapa kali pun dijalankan dengan isi sumber yang sama, keadaan akhirnya selalu sama. Eksekusi pertama menyalin semuanya; eksekusi kedua tidak memindahkan apa pun karena tidak ada yang berubah; eksekusi setelah perubahan hanya membawa perubahan itu. Jadwal yang salah jam, dieksekusi ulang manual, atau dijalankan bersamaan semuanya aman — hasil akhirnya tetap mirror yang identik.
Sifat ini tidak bisa diklamin begitu saja; ia harus dibuktikan. Uji kita nanti memakai tiga bukti: eksekusi kedua melaporkan pindah=0, diff -r antara potret mirror dan mirror terbaru tidak melaporkan beda, dan diff -r sumber mirror bersih. Kalau salah satu gagal, tugas belum layak dijadwalkan.
Argumen, Kode Keluar, dan Waktu yang Bisa Ditentukan #
Skrip menerima dua opsi dan satu variabel lingkungan. Opsi --dry-run menjalankan rsync dalam mode pratinjau: semua perubahan yang akan dilakukan ditampilkan, tetapi tidak satu berkas pun tersentuh — dan log pun tidak ditulis. Opsi --verbose menambahkan daftar perubahan per berkas pada eksekusi sungguhan. Variabel STAMP menentukan “waktu eksekusi” yang tercatat; bila tidak diisi, skrip memakai waktu saat itu.
Kode keluar mengikuti konvensi yang sudah kita kenal — satu nomor untuk satu jenis masalah, sehingga scheduler maupun kita bisa membedakan penyebab tanpa membaca seluruh log:
| Kode | Arti |
|---|---|
0 | sukses — mirror kini identik dengan sumber |
1 | opsi tidak dikenal (pemakaian salah) |
2 | folder sumber tidak ditemukan |
3 | rsync gagal |
Menulis dan Menguji tugas.sh #
Blok berikut menulis skrip lengkap — identik dengan src/tugas.sh pada bagian Berkas Sumber — lalu menjalankan seluruh pembuktian yang kita rancang: dua eksekusi berturut-turut dengan diff, satu --dry-run, dan satu eksekusi --verbose yang membawa berkas baru:
mkdir -p otomasi/sumber/laporan
printf 'daftar tugas minggu ini\n' > otomasi/sumber/tugas.txt
printf 'laporan penjualan kuartal pertama\n' > otomasi/sumber/laporan/penjualan.txt
printf 'catatan rapat tim mingguan\n' > otomasi/sumber/laporan/rapat.txt
# Bekukan mtime fixture agar output rsync (itemize) deterministik.
find otomasi/sumber -exec touch -d '2026-01-15 07:55:00' {} +
cat > otomasi/tugas.sh <<'BASH'
#!/usr/bin/env bash
# tugas.sh — tugas terjadwal: sinkronisasi folder sumber ke mirror.
#
# Tugas ini dirancang untuk dipanggil scheduler (cron / systemd timer):
# - idempoten: berapa kali pun dijalankan, keadaan akhir mirror sama,
# - lokasi kerja dihitung dari letak skrip, bukan cwd pemanggil,
# sehingga aman dipanggil dari environment minimal milik cron,
# - setiap eksekusi menuliskan SATU baris log terstruktur yang bisa
# dibaca cek-jadwal.sh.
#
# Pemakaian:
# tugas.sh [--dry-run] [--verbose]
#
# Opsi:
# --dry-run tampilkan perubahan yang akan dilakukan tanpa menjalankannya
# --verbose tampilkan daftar perubahan per berkas
#
# Variabel lingkungan (berguna untuk pengujian deterministik):
# TUGAS_SRC folder sumber (default: <letak skrip>/sumber)
# TUGAS_DEST folder mirror (default: <letak skrip>/mirror)
# TUGAS_LOG berkas log (default: <letak skrip>/log/tugas.log)
# STAMP waktu eksekusi, format YYYYMMDDTHHMMSS (default: sekarang)
#
# Kode keluar:
# 0 sukses 1 opsi tidak dikenal
# 2 folder sumber tidak ada 3 rsync gagal
#
# Catatan: kunci anti-dobel (flock) sengaja TIDAK ada di skrip ini;
# kunci dipasang di lapisan scheduler (baris crontab / ExecStart unit)
# supaya satu berkas kunci melindungi kedua cara penjadwalan.
set -u
# Lokasi kerja dihitung dari letak skrip, bukan cwd pemanggil.
BASE=$(cd "$(dirname "$0")" && pwd)
TUGAS_SRC="${TUGAS_SRC:-$BASE/sumber}"
TUGAS_DEST="${TUGAS_DEST:-$BASE/mirror}"
TUGAS_LOG="${TUGAS_LOG:-$BASE/log/tugas.log}"
STAMP="${STAMP:-$(date +%Y%m%dT%H%M%S)}"
DRY_RUN=0
VERBOSE=0
for arg in "$@"; do
case $arg in
--dry-run) DRY_RUN=1 ;;
--verbose) VERBOSE=1 ;;
*)
printf 'tugas.sh: opsi tidak dikenal: %s\n' "$arg" >&2
printf 'pemakaian: tugas.sh [--dry-run] [--verbose]\n' >&2
exit 1
;;
esac
done
# waktu() : bentuk terbaca dari STAMP untuk baris-ringkasan di stdout.
waktu() {
printf '%s-%s-%s %s:%s:%s' \
"${STAMP:0:4}" "${STAMP:4:2}" "${STAMP:6:2}" \
"${STAMP:9:2}" "${STAMP:11:2}" "${STAMP:13:2}"
}
# Siapkan folder log sebelum bisa mencatat apa pun.
mkdir -p "$(dirname "$TUGAS_LOG")"
SECONDS=0
# --- 1. Validasi sumber --------------------------------------------------
if [ ! -d "$TUGAS_SRC" ]; then
printf '%s|GAGAL|keluar=2|durasi=%ds|sumber tidak ditemukan: %s\n' \
"$STAMP" "${TUGAS_DURASI:-$SECONDS}" "$TUGAS_SRC" >> "$TUGAS_LOG"
printf 'tugas.sh: folder sumber tidak ditemukan: %s\n' "$TUGAS_SRC" >&2
exit 2
fi
# --- 2. Sinkronisasi rsync ------------------------------------------------
# --itemize-changes membuat rsync mencetak satu baris per perubahan
# (">f..." berkas dipindah, "*deleting" berkas dihapus); baris itulah
# yang dihitung menjadi ringkasan.
Opsi=(-a --delete --itemize-changes)
[ "$DRY_RUN" -eq 1 ] && Opsi+=(--dry-run)
if ! ubah=$(rsync "${Opsi[@]}" "$TUGAS_SRC/" "$TUGAS_DEST/"); then
printf '%s|GAGAL|keluar=3|durasi=%ds|rsync gagal\n' \
"$STAMP" "${TUGAS_DURASI:-$SECONDS}" >> "$TUGAS_LOG"
printf 'tugas.sh: rsync gagal\n' >&2
exit 3
fi
total=$(find "$TUGAS_SRC" -type f | wc -l)
pindah=$(printf '%s\n' "$ubah" | grep -c '^[<>]f' || true)
hapus=$(printf '%s\n' "$ubah" | grep -c '^\*deleting' || true)
# --- 3. Dry-run: pratinjau tanpa mengubah apa pun, termasuk log -----------
if [ "$DRY_RUN" -eq 1 ]; then
printf '[%s] dry-run: %s -> %s\n' "$(waktu)" "$TUGAS_SRC" "$TUGAS_DEST"
[ -n "$ubah" ] && printf '%s\n' "$ubah"
printf '[%s] tidak ada yang diubah (dry-run)\n' "$(waktu)"
exit 0
fi
# --- 4. Catat log terstruktur + ringkasan ---------------------------------
printf '%s|OK|keluar=0|durasi=%ds|total=%d|pindah=%d|hapus=%d\n' \
"$STAMP" "${TUGAS_DURASI:-$SECONDS}" "$total" "$pindah" "$hapus" >> "$TUGAS_LOG"
printf '[%s] mulai sinkronisasi: %s -> %s\n' "$(waktu)" "$TUGAS_SRC" "$TUGAS_DEST"
[ "$VERBOSE" -eq 1 ] && [ -n "$ubah" ] && printf '%s\n' "$ubah"
printf '[%s] %d berkas diperiksa, %d dipindah, %d dihapus\n' \
"$(waktu)" "$total" "$pindah" "$hapus"
printf '[%s] selesai: OK (%d detik)\n' "$(waktu)" "${TUGAS_DURASI:-$SECONDS}"
exit 0
BASH
echo "== eksekusi pertama =="
STAMP=20260115T080000 TUGAS_DURASI=1 bash otomasi/tugas.sh
echo "== potret hasil, lalu eksekusi kedua =="
cp -a otomasi/mirror potret-1
STAMP=20260115T080500 TUGAS_DURASI=0 bash otomasi/tugas.sh
diff -r potret-1 otomasi/mirror && echo "mirror tidak berubah setelah eksekusi kedua"
diff -r otomasi/sumber otomasi/mirror && echo "mirror identik dengan sumber"
echo "== log terstruktur =="
cat otomasi/log/tugas.log
echo "== berkas baru + dry-run =="
printf 'catatan tambahan\n' > otomasi/sumber/catatan-baru.txt
STAMP=20260115T081000 bash otomasi/tugas.sh --dry-run
ls otomasi/mirror
echo "== dry-run tidak menulis log =="
wc -l < otomasi/log/tugas.log
echo "== eksekusi ketiga membawa berkas baru (verbose) =="
STAMP=20260115T081500 TUGAS_DURASI=0 bash otomasi/tugas.sh --verbose
cat otomasi/log/tugas.logOutput yang Dihasilkan:
== eksekusi pertama ==
[2026-01-15 08:00:00] mulai sinkronisasi: /home/budi/otomasi/sumber -> /home/budi/otomasi/mirror
[2026-01-15 08:00:00] 3 berkas diperiksa, 3 dipindah, 0 dihapus
[2026-01-15 08:00:00] selesai: OK (1 detik)
== potret hasil, lalu eksekusi kedua ==
[2026-01-15 08:05:00] mulai sinkronisasi: /home/budi/otomasi/sumber -> /home/budi/otomasi/mirror
[2026-01-15 08:05:00] 3 berkas diperiksa, 0 dipindah, 0 dihapus
[2026-01-15 08:05:00] selesai: OK (0 detik)
mirror tidak berubah setelah eksekusi kedua
mirror identik dengan sumber
== log terstruktur ==
20260115T080000|OK|keluar=0|durasi=1s|total=3|pindah=3|hapus=0
20260115T080500|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
== berkas baru + dry-run ==
[2026-01-15 08:10:00] dry-run: /home/budi/otomasi/sumber -> /home/budi/otomasi/mirror
.d..t...... ./
>f+++++++++ catatan-baru.txt
[2026-01-15 08:10:00] tidak ada yang diubah (dry-run)
laporan
tugas.txt
== dry-run tidak menulis log ==
2
== eksekusi ketiga membawa berkas baru (verbose) ==
[2026-01-15 08:15:00] mulai sinkronisasi: /home/budi/otomasi/sumber -> /home/budi/otomasi/mirror
.d..t...... ./
>f+++++++++ catatan-baru.txt
[2026-01-15 08:15:00] 4 berkas diperiksa, 1 dipindah, 0 dihapus
[2026-01-15 08:15:00] selesai: OK (0 detik)
20260115T080000|OK|keluar=0|durasi=1s|total=3|pindah=3|hapus=0
20260115T080500|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260115T081500|OK|keluar=0|durasi=0s|total=4|pindah=1|hapus=0Mari kita bedah bagian-bagian kuncinya:
BASE=$(cd "$(dirname "$0")" && pwd): skrip menemukan sendiri letaknya, lalu menurunkan seluruh jalur kerja —sumber,mirror,log— dariBASE, bukan dari direktori kerja pemanggil. Inilah yang membuat skrip aman untuk scheduler: cron memanggil dari$HOME, systemd dari/, dan keduanya tetap menemukan folderotomasiyang sama.STAMP="${STAMP:-$(date +%Y%m%dT%H%M%S)}": pola parameter expansion memberi nilai bawaan (waktu nyata) sekaligus mengizinkan override dari lingkungan —STAMP=20260115T080000 bash tugas.shmembuat seluruh jejak eksekusi bisa direproduksi.- Loop
for arg in "$@"dengancase: opsi yang tidak dikenal ditolak lewat stderr dengan pesan pemakaian, bukan diabaikan diam-diam. Salah ketik--dryrunharus terdengar saat itu juga, bukan dua minggu kemudian di log. Opsi=(-a --delete --itemize-changes): array bash menjaga setiap opsi tetap satu argumen tersendiri;--dry-runcukup ditambahkan denganOpsi+=(--dry-run). Rincian perilaku-adan--deletedibahas tuntas di Chapter Rsync; daftar lengkap opsinya ada di rsync(1) .ubah=$(rsync ...)dan cabangif !: seluruh keluaran--itemize-changesditangkap ke variabelubah— rsync yang gagal (rsynckeluar tidak nol) langsung menulis barisGAGALke log lalu keluar 3, tanpa melanjutkan ke ringkasan yang akan menyesatkan.grep -c '^[<>]f'dangrep -c '^\*deleting': itemize rsync memberi kode satu huruf per baris —>f/<fberarti berkas dipindahkan,*deletingberarti berkas dihapus dari mirror. Dua hitungan itulah isi ringkasan; pada eksekusi kedua keduanya0, itulah bukti numeric idempotensi.- Blok dry-run
exit 0sebelum penulisan log: pratinjau berhenti tepat sebelum efek apa pun terjadi — tidak ada berkas tersalin, tidak ada baris log.ls otomasi/mirrordanwc -lpada blok di atas membuktikan keduanya. printf '%s|OK|keluar=0|...' >> "$TUGAS_LOG": satu baris per eksekusi, dipisah tanda|— format terstruktur ini yang menjadi bahan bakucek-jadwal.shpada Bagian 3.
Perhatikan urutan bukti pada blok di atas: pindah=0 pada eksekusi kedua, diff -r yang diam dua kali, ls yang tidak memuat catatan-baru.txt setelah dry-run, dan wc -l yang tetap 2 — baris log ketiga baru muncul setelah eksekusi sungguhan. Tugas kita kini terbukti idempoten dan punya pratinjau yang jujur. Pekerjaan selanjutnya bukan lagi menulis kode, melainkan memasangnya pada mesin pengulangan — Bagian 2 menyangkutkannya ke crontab dan systemd timer sekaligus.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/cek-jadwal.sh
Unduh berkas#!/usr/bin/env bash
# cek-jadwal.sh — periksa kesehatan tugas terjadwal dari berkas lognya.
#
# Membaca log terstruktur tugas.sh dan menjawab tiga pertanyaan admin:
# kapan eksekusi terakhir, berapa jam sejak sukses terakhir, dan sudah
# berapa kali gagal berturut-turut. Cocok dijalankan manual saat curiga
# jadwal macet, atau dijadwalkan sendiri sebagai tugas kedua.
#
# Pemakaian:
# cek-jadwal.sh <berkas-log> [batas-jam]
#
# batas-jam : berapa jam tanpa sukses sebelum dianggap TERLAMPAT
# (default 25: jadwal harian + toleransi 1 jam)
# NOW : variabel lingkungan "waktu sekarang", format
# YYYYMMDDTHHMMSS (default: waktu nyata). Dipakai agar
# pengujian umur log deterministik.
#
# Kode keluar:
# 0 SEHAT 1 TERLAMPAT / belum ada eksekusi
# 2 GAGAL berturut-turut 3 berkas log tidak ditemukan
set -u
LOG="${1:-}"
BATAS="${2:-25}"
NOW="${NOW:-$(date +%Y%m%dT%H%M%S)}"
if [ -z "$LOG" ]; then
printf 'pemakaian: cek-jadwal.sh <berkas-log> [batas-jam]\n' >&2
exit 3
fi
if [ ! -f "$LOG" ]; then
printf 'cek-jadwal.sh: berkas log tidak ditemukan: %s\n' "$LOG" >&2
exit 3
fi
# ke_epoch() : ubah STAMP (YYYYMMDDTHHMMSS) menjadi detik epoch (UTC).
ke_epoch() {
date -u -d "${1:0:4}-${1:4:2}-${1:6:2} ${1:9:2}:${1:11:2}:${1:13:2}" +%s
}
# tampil() : bentuk terbaca dari STAMP untuk laporan.
tampil() {
printf '%s-%s-%s %s:%s:%s' \
"${1:0:4}" "${1:4:2}" "${1:6:2}" "${1:9:2}" "${1:11:2}" "${1:13:2}"
}
# --- 1. Baca seluruh baris log -------------------------------------------
n=0
terakhir_status=""
terakhir_stamp=""
terakhir_keluar=""
sukses_stamp=""
berturut=0
while IFS='|' read -r stamp status sisa; do
# Hanya baris berformat STAMP|STATUS|... yang diperhitungkan.
case $stamp in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T*) ;;
*) continue ;;
esac
n=$((n + 1))
terakhir_stamp=$stamp
terakhir_status=$status
keluar=${sisa%%|*}
terakhir_keluar=${keluar#keluar=}
if [ "$status" = "OK" ]; then
sukses_stamp=$stamp
berturut=0
elif [ "$status" = "GAGAL" ]; then
berturut=$((berturut + 1))
fi
done < "$LOG"
# --- 2. Laporan -----------------------------------------------------------
printf 'log : %s\n' "$LOG"
printf 'eksekusi : %d\n' "$n"
if [ "$n" -eq 0 ]; then
printf 'status : BELUM — belum ada eksekusi tercatat\n'
exit 1
fi
if [ "$terakhir_status" = "GAGAL" ]; then
printf 'terakhir : GAGAL pada %s (keluar=%s)\n' \
"$(tampil "$terakhir_stamp")" "$terakhir_keluar"
else
printf 'terakhir : OK pada %s\n' "$(tampil "$terakhir_stamp")"
fi
if [ -n "$sukses_stamp" ]; then
sukses_epoch=$(ke_epoch "$sukses_stamp")
umur=$(( ($(ke_epoch "$NOW") - sukses_epoch) / 3600 ))
printf 'sukses : %s (%d jam lalu)\n' "$(tampil "$sukses_stamp")" "$umur"
else
umur=999999
printf 'sukses : belum pernah\n'
fi
printf 'berturut : %d gagal beruntun\n' "$berturut"
# --- 3. Klasifikasi -------------------------------------------------------
# Gagal berturut-turut paling parah: tugas jalan tapi selalu gagal.
# Umur melebihi batas: tugas tidak jalan (atau jalan tanpa hasil).
if [ "$berturut" -ge 2 ]; then
printf 'status : GAGAL — %d kali gagal berturut-turut\n' "$berturut"
exit 2
fi
if [ -z "$sukses_stamp" ]; then
printf 'status : TERLAMPAT — belum pernah sukses (batas %d jam)\n' "$BATAS"
exit 1
fi
if [ "$umur" -gt "$BATAS" ]; then
printf 'status : TERLAMBAT — %d jam tanpa sukses (batas %d jam)\n' \
"$umur" "$BATAS"
exit 1
fi
printf 'status : SEHAT — sukses terakhir %d jam lalu\n' "$umur"
exit 0
src/runner.sh
Unduh berkas#!/usr/bin/env bash
# runner.sh — tiruan scheduler (cron / systemd timer) untuk tugas.sh.
#
# Lingkungan latihan tidak menjalankan crond maupun systemd timer, jadi
# runner.sh memainkan peran scheduler: memanggil tugas persis seperti
# baris crontab / ExecStart unit melakukannya — environment minimal,
# dilindungi flock -n, stdout+stderr ditangkap redirect — lalu
# melaporkan kode keluarnya. STAMP menjadi "waktu eksekusi" sehingga
# jadwal bisa direproduksi kapan pun tanpa menunggu jam 08:00.
#
# Pemakaian:
# STAMP=20260115T080000 bash runner.sh [folder-tugas]
#
# folder-tugas : folder berisi tugas.sh (default: folder saat ini)
#
# Kode keluar:
# 0 tugas sukses 1 kunci flock sibuk (tugas ditolak)
# 2/3 kode keluar tugas.sh lainnya
set -u
DIR="${1:-.}"
STAMP="${STAMP:-$(date +%Y%m%dT%H%M%S)}"
mkdir -p "$DIR/log"
# env -i membersihkan lingkungan seperti milik cron: hanya PATH minimal
# dan HOME yang diteruskan, ditambah STAMP untuk determinisme.
env -i PATH=/usr/local/bin:/usr/bin:/bin HOME="$DIR" STAMP="$STAMP" \
flock -n "$DIR/.tugas.lock" /bin/bash "$DIR/tugas.sh" \
>> "$DIR/log/tugas-output.log" 2>&1
rc=$?
printf 'scheduler: tugas keluar=%d\n' "$rc"
exit "$rc"
src/tugas.sh
Unduh berkas#!/usr/bin/env bash
# tugas.sh — tugas terjadwal: sinkronisasi folder sumber ke mirror.
#
# Tugas ini dirancang untuk dipanggil scheduler (cron / systemd timer):
# - idempoten: berapa kali pun dijalankan, keadaan akhir mirror sama,
# - lokasi kerja dihitung dari letak skrip, bukan cwd pemanggil,
# sehingga aman dipanggil dari environment minimal milik cron,
# - setiap eksekusi menuliskan SATU baris log terstruktur yang bisa
# dibaca cek-jadwal.sh.
#
# Pemakaian:
# tugas.sh [--dry-run] [--verbose]
#
# Opsi:
# --dry-run tampilkan perubahan yang akan dilakukan tanpa menjalankannya
# --verbose tampilkan daftar perubahan per berkas
#
# Variabel lingkungan (berguna untuk pengujian deterministik):
# TUGAS_SRC folder sumber (default: <letak skrip>/sumber)
# TUGAS_DEST folder mirror (default: <letak skrip>/mirror)
# TUGAS_LOG berkas log (default: <letak skrip>/log/tugas.log)
# STAMP waktu eksekusi, format YYYYMMDDTHHMMSS (default: sekarang)
#
# Kode keluar:
# 0 sukses 1 opsi tidak dikenal
# 2 folder sumber tidak ada 3 rsync gagal
#
# Catatan: kunci anti-dobel (flock) sengaja TIDAK ada di skrip ini;
# kunci dipasang di lapisan scheduler (baris crontab / ExecStart unit)
# supaya satu berkas kunci melindungi kedua cara penjadwalan.
set -u
# Lokasi kerja dihitung dari letak skrip, bukan cwd pemanggil.
BASE=$(cd "$(dirname "$0")" && pwd)
TUGAS_SRC="${TUGAS_SRC:-$BASE/sumber}"
TUGAS_DEST="${TUGAS_DEST:-$BASE/mirror}"
TUGAS_LOG="${TUGAS_LOG:-$BASE/log/tugas.log}"
STAMP="${STAMP:-$(date +%Y%m%dT%H%M%S)}"
DRY_RUN=0
VERBOSE=0
for arg in "$@"; do
case $arg in
--dry-run) DRY_RUN=1 ;;
--verbose) VERBOSE=1 ;;
*)
printf 'tugas.sh: opsi tidak dikenal: %s\n' "$arg" >&2
printf 'pemakaian: tugas.sh [--dry-run] [--verbose]\n' >&2
exit 1
;;
esac
done
# waktu() : bentuk terbaca dari STAMP untuk baris-ringkasan di stdout.
waktu() {
printf '%s-%s-%s %s:%s:%s' \
"${STAMP:0:4}" "${STAMP:4:2}" "${STAMP:6:2}" \
"${STAMP:9:2}" "${STAMP:11:2}" "${STAMP:13:2}"
}
# Siapkan folder log sebelum bisa mencatat apa pun.
mkdir -p "$(dirname "$TUGAS_LOG")"
SECONDS=0
# --- 1. Validasi sumber --------------------------------------------------
if [ ! -d "$TUGAS_SRC" ]; then
printf '%s|GAGAL|keluar=2|durasi=%ds|sumber tidak ditemukan: %s\n' \
"$STAMP" "${TUGAS_DURASI:-$SECONDS}" "$TUGAS_SRC" >> "$TUGAS_LOG"
printf 'tugas.sh: folder sumber tidak ditemukan: %s\n' "$TUGAS_SRC" >&2
exit 2
fi
# --- 2. Sinkronisasi rsync ------------------------------------------------
# --itemize-changes membuat rsync mencetak satu baris per perubahan
# (">f..." berkas dipindah, "*deleting" berkas dihapus); baris itulah
# yang dihitung menjadi ringkasan.
Opsi=(-a --delete --itemize-changes)
[ "$DRY_RUN" -eq 1 ] && Opsi+=(--dry-run)
if ! ubah=$(rsync "${Opsi[@]}" "$TUGAS_SRC/" "$TUGAS_DEST/"); then
printf '%s|GAGAL|keluar=3|durasi=%ds|rsync gagal\n' \
"$STAMP" "${TUGAS_DURASI:-$SECONDS}" >> "$TUGAS_LOG"
printf 'tugas.sh: rsync gagal\n' >&2
exit 3
fi
total=$(find "$TUGAS_SRC" -type f | wc -l)
pindah=$(printf '%s\n' "$ubah" | grep -c '^[<>]f' || true)
hapus=$(printf '%s\n' "$ubah" | grep -c '^\*deleting' || true)
# --- 3. Dry-run: pratinjau tanpa mengubah apa pun, termasuk log -----------
if [ "$DRY_RUN" -eq 1 ]; then
printf '[%s] dry-run: %s -> %s\n' "$(waktu)" "$TUGAS_SRC" "$TUGAS_DEST"
[ -n "$ubah" ] && printf '%s\n' "$ubah"
printf '[%s] tidak ada yang diubah (dry-run)\n' "$(waktu)"
exit 0
fi
# --- 4. Catat log terstruktur + ringkasan ---------------------------------
printf '%s|OK|keluar=0|durasi=%ds|total=%d|pindah=%d|hapus=%d\n' \
"$STAMP" "${TUGAS_DURASI:-$SECONDS}" "$total" "$pindah" "$hapus" >> "$TUGAS_LOG"
printf '[%s] mulai sinkronisasi: %s -> %s\n' "$(waktu)" "$TUGAS_SRC" "$TUGAS_DEST"
[ "$VERBOSE" -eq 1 ] && [ -n "$ubah" ] && printf '%s\n' "$ubah"
printf '[%s] %d berkas diperiksa, %d dipindah, %d dihapus\n' \
"$(waktu)" "$total" "$pindah" "$hapus"
printf '[%s] selesai: OK (%d detik)\n' "$(waktu)" "${TUGAS_DURASI:-$SECONDS}"
exit 0
tests/run-tests.sh
Unduh berkas#!/usr/bin/env bash
# run-tests.sh — test suite untuk paket otomasi-dengan-cron-dan-systemd.
#
# Suite menyiapkan folder kerja terisolasi (mktemp) beserta data
# contohnya, menyalin skrip dari src/ dan berkas dari config/, lalu
# menjalankan rangkaian pengujian dengan assertion sederhana:
# - idempotensi tugas.sh (dua eksekusi, hasil akhir identik),
# - --dry-run tidak mengubah mirror maupun log,
# - baris crontab lolos validator sintaks 5-field dan benar-benar
# bisa dijalankan (jalur disesuaikan ke folder kerja),
# - unit systemd lolos systemd-analyze verify,
# - flock menolak eksekusi ganda lewat baris crontab dan runner.sh,
# - cek-jadwal.sh mengklasifikasi tiga kondisi log dengan benar.
#
# Jalankan dari mana saja: bash tests/run-tests.sh
set -u
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PROJ_DIR=$(dirname "$SCRIPT_DIR")
PASS=0
FAIL=0
# assert_eq <harapan> <nyata> <pesan>: bandingkan dua nilai.
assert_eq() {
if [ "$1" = "$2" ]; then
PASS=$((PASS + 1))
printf 'OK %s\n' "$3"
else
FAIL=$((FAIL + 1))
printf 'GAGAL %s: harapan %s, nyata %s\n' "$3" "$1" "$2"
fi
}
# --- siapkan folder kerja terisolasi ---------------------------------------
WORK=$(mktemp -d)
cp "$PROJ_DIR/src/tugas.sh" "$PROJ_DIR/src/cek-jadwal.sh" \
"$PROJ_DIR/src/runner.sh" "$WORK/"
cd "$WORK" || exit 1
# Data contoh (fixture) untuk tugas sinkronisasi.
mkdir -p sumber/laporan
printf 'daftar tugas minggu ini\n' > sumber/tugas.txt
printf 'laporan penjualan kuartal pertama\n' > sumber/laporan/penjualan.txt
# --- uji 1: eksekusi pertama sukses ---------------------------------------
STAMP=20260115T080000 bash tugas.sh > /dev/null 2>&1
assert_eq 0 "$?" "eksekusi pertama keluar 0"
diff -r sumber mirror > /dev/null 2>&1
assert_eq 0 "$?" "mirror identik dengan sumber"
# --- uji 2: idempotensi — eksekusi ulang tidak mengubah hasil -------------
cp -a mirror potret-1
STAMP=20260115T080500 bash tugas.sh > /dev/null 2>&1
assert_eq 0 "$?" "eksekusi kedua keluar 0"
diff -r potret-1 mirror > /dev/null 2>&1
assert_eq 0 "$?" "eksekusi ulang tidak mengubah mirror"
pindah2=$(sed -n '2p' log/tugas.log | grep -c 'pindah=0')
assert_eq 1 "$pindah2" "eksekusi kedua tidak memindah berkas (idempoten)"
# --- uji 3: dry-run tidak mengubah mirror maupun log -----------------------
printf 'catatan baru\n' > sumber/catatan-baru.txt
n_log=$(wc -l < log/tugas.log)
STAMP=20260115T081000 bash tugas.sh --dry-run > /dev/null 2>&1
assert_eq 0 "$?" "dry-run keluar 0"
[ ! -f mirror/catatan-baru.txt ]
assert_eq 0 "$?" "dry-run tidak menyalin berkas baru"
n_log2=$(wc -l < log/tugas.log)
assert_eq "$n_log" "$n_log2" "dry-run tidak menulis log"
# --- uji 4: eksekusi berikutnya membawa berkas baru ------------------------
STAMP=20260115T081500 bash tugas.sh > /dev/null 2>&1
assert_eq 0 "$?" "eksekusi ketiga keluar 0"
diff -r sumber mirror > /dev/null 2>&1
assert_eq 0 "$?" "mirror mengikuti sumber terbaru"
# --- uji 5: opsi salah dan sumber hilang ----------------------------------
bash tugas.sh --salah-opsi > /dev/null 2>&1
assert_eq 1 "$?" "opsi tidak dikenal keluar 1"
TUGAS_SRC="$WORK/tidak-ada" STAMP=20260116T080000 bash tugas.sh > /dev/null 2>&1
assert_eq 2 "$?" "sumber hilang keluar 2"
gagal=$(tail -n 1 log/tugas.log | grep -c 'GAGAL|keluar=2')
assert_eq 1 "$gagal" "kegagalan sumber tercatat di log"
# --- uji 6: crontab lolos validator sintaks 5-field ------------------------
cat > validasi-crontab.awk <<'AWK'
# validasi-crontab.awk — validator sintaks 5-field untuk berkas crontab.
function cek_field(v, lo, hi, daftar, n, i, bagian, pos, langkah, rentang) {
n = split(v, daftar, ",")
for (i = 1; i <= n; i++) {
bagian = daftar[i]
pos = index(bagian, "/")
if (pos > 0) {
langkah = substr(bagian, pos + 1)
if (langkah !~ /^[0-9]+$/ || langkah + 0 < 1)
return 0
bagian = substr(bagian, 1, pos - 1)
}
if (bagian == "*")
continue
if (bagian ~ /^[0-9]+$/) {
if (bagian + 0 < lo || bagian + 0 > hi)
return 0
} else if (bagian ~ /^[0-9]+-[0-9]+$/) {
split(bagian, rentang, "-")
if (rentang[1] + 0 < lo || rentang[2] + 0 > hi)
return 0
if (rentang[1] + 0 > rentang[2] + 0)
return 0
} else {
return 0
}
}
return 1
}
/^[ \t]*#/ || /^[ \t]*$/ { next }
/^[A-Za-z_][A-Za-z0-9_]*=/ { printf "BARIS %d: pengaturan %s\n", NR, $1; next }
{
jumlah++
if (NF < 6) {
printf "BARIS %d SALAH: perlu 5 field jadwal + perintah\n", NR
salah++
next
}
if (!cek_field($1, 0, 59)) { printf "BARIS %d SALAH: menit %s\n", NR, $1; salah++; next }
if (!cek_field($2, 0, 23)) { printf "BARIS %d SALAH: jam %s\n", NR, $2; salah++; next }
if (!cek_field($3, 1, 31)) { printf "BARIS %d SALAH: tanggal %s\n", NR, $3; salah++; next }
if (!cek_field($4, 1, 12)) { printf "BARIS %d SALAH: bulan %s\n", NR, $4; salah++; next }
if (!cek_field($5, 0, 7)) { printf "BARIS %d SALAH: hari %s\n", NR, $5; salah++; next }
printf "BARIS %d OK: %s %s %s %s %s\n", NR, $1, $2, $3, $4, $5
}
END {
printf "%d baris jadwal diperiksa, %d SALAH\n", jumlah + 0, salah + 0
if (salah > 0)
exit 1
}
AWK
awk -f validasi-crontab.awk "$PROJ_DIR/config/tugas.crontab" > hasil-vaildasi.txt
assert_eq 0 "$?" "config/tugas.crontab lolos validator 5-field"
ringkasan=$(tail -n 1 hasil-vaildasi.txt)
assert_eq "1 baris jadwal diperiksa, 0 SALAH" "$ringkasan" "validator menemukan 1 jadwal valid"
cat > salah-jadwal.crontab <<'CRON'
# salah-jadwal.crontab — fixture berisi jadwal yang salah.
SHELL=/bin/bash
0 8 * * *
0 25 * * * /home/budi/otomasi/tugas.sh
61 * * * * /home/budi/otomasi/tugas.sh
CRON
awk -f validasi-crontab.awk salah-jadwal.crontab > hasil-salah.txt
assert_eq 1 "$?" "validator menolak jadwal yang salah (keluar 1)"
n_salah=$(grep -c 'SALAH:' hasil-salah.txt)
assert_eq 3 "$n_salah" "tiga baris salah terdeteksi"
# --- uji 7: baris crontab benar-benar bisa dijalankan ----------------------
# Ambil baris jadwal pertama, ganti jalur instalasi ke folder kerja,
# lalu jalankan persis seperti yang akan dilakukan cron.
baris=$(awk '!/^[ \t]*#/ && !/^[ \t]*$/ && !/^[A-Za-z_][A-Za-z0-9_]*=/ { print; exit }' \
"$PROJ_DIR/config/tugas.crontab")
read -r _m _h _dm _mo _dw cmd <<< "$baris"
cmd=${cmd//\/home\/budi\/otomasi/$WORK}
mkdir -p log
STAMP=20260117T080000 bash -c "$cmd"
assert_eq 0 "$?" "baris crontab (jalur disesuaikan) sukses dijalankan"
n_ok=$(grep -c '^20260117T080000|OK' log/tugas.log)
assert_eq 1 "$n_ok" "eksekusi via baris crontab tercatat di log"
[ -s log/tugas-output.log ]
assert_eq 0 "$?" "output tugas tertangkap redirect crontab"
# --- uji 8: flock menolak eksekusi ganda ------------------------------------
flock "$WORK/.tugas.lock" sleep 1 &
sleep 0.3
n_sebelum=$(wc -l < log/tugas.log)
STAMP=20260117T080500 bash -c "$cmd" > /dev/null 2>&1
assert_eq 1 "$?" "baris crontab ditolak flock saat kunci sibuk"
n_sesudah=$(wc -l < log/tugas.log)
assert_eq "$n_sebelum" "$n_sesudah" "eksekusi yang ditolak tidak menulis log"
wait
# --- uji 9: unit systemd lolos verify ----------------------------------------
systemd-analyze verify "$PROJ_DIR/config/tugas-sinkron.service" \
"$PROJ_DIR/config/tugas-sinkron.timer" > /dev/null 2>&1
assert_eq 0 "$?" "unit service+timer lolos systemd-analyze verify"
# --- uji 10: runner menjalankan tugas seperti scheduler ----------------------
STAMP=20260118T080000 bash runner.sh "$WORK" > /dev/null 2>&1
assert_eq 0 "$?" "runner menjalankan tugas seperti scheduler"
n_ok=$(grep -c '^20260118T080000|OK' log/tugas.log)
assert_eq 1 "$n_ok" "eksekusi via runner tercatat di log"
# --- uji 11: runner ditolak saat kunci sibuk (dobel) ------------------------
flock "$WORK/.tugas.lock" sleep 1 &
sleep 0.3
n_sebelum=$(wc -l < log/tugas.log)
STAMP=20260118T080500 bash runner.sh "$WORK" > /dev/null 2>&1
assert_eq 1 "$?" "runner ditolak flock saat tugas lain berjalan"
n_sesudah=$(wc -l < log/tugas.log)
assert_eq "$n_sebelum" "$n_sesudah" "runner yang ditolak tidak menulis log"
wait
# --- uji 12: cek-jadwal mengklasifikasi tiga kondisi log --------------------
cat > log-sehat.log <<'FIX'
20260113T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260114T080000|OK|keluar=0|durasi=0s|total=3|pindah=1|hapus=0
20260115T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260116T080000|OK|keluar=0|durasi=0s|total=3|pindah=2|hapus=0
FIX
cat > log-terlambat.log <<'FIX'
20260112T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260113T080000|OK|keluar=0|durasi=0s|total=3|pindah=2|hapus=1
FIX
cat > log-gagal.log <<'FIX'
20260114T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
20260115T080000|GAGAL|keluar=3|durasi=0s|rsync gagal
20260116T080000|GAGAL|keluar=3|durasi=0s|rsync gagal
FIX
NOW=20260116T120000 bash cek-jadwal.sh log-sehat.log > laporan-sehat.txt
assert_eq 0 "$?" "log sehat diklasifikasi SEHAT (keluar 0)"
n=$(grep -c 'sukses terakhir 4 jam lalu' laporan-sehat.txt)
assert_eq 1 "$n" "umur sukses dihitung: 4 jam"
NOW=20260116T120000 bash cek-jadwal.sh log-terlambat.log > laporan-terlambat.txt
assert_eq 1 "$?" "log berhenti diklasifikasi TERLAMBAT (keluar 1)"
n=$(grep -c '76 jam tanpa sukses' laporan-terlambat.txt)
assert_eq 1 "$n" "umur tanpa sukses dihitung: 76 jam"
NOW=20260116T120000 bash cek-jadwal.sh log-gagal.log > laporan-gagal.txt
assert_eq 2 "$?" "log gagal berturut diklasifikasi GAGAL (keluar 2)"
n=$(grep -c '2 kali gagal berturut-turut' laporan-gagal.txt)
assert_eq 1 "$n" "gagal berturut terdeteksi: 2 kali"
bash cek-jadwal.sh log-tidak-ada.log > /dev/null 2>&1
assert_eq 3 "$?" "berkas log yang hilang dilaporkan (keluar 3)"
# --- ringkasan -----------------------------------------------------------------
if [ "$FAIL" -eq 0 ]; then
printf 'PASSED: %d/%d test lulus\n' "$PASS" "$PASS"
else
printf 'FAILED: %d/%d test lulus\n' "$PASS" "$((PASS + FAIL))"
fi
cd / && rm -rf "$WORK"
[ "$FAIL" -eq 0 ]