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 --delete menyalin isi sumber ke mirror — 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:

text
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:

KodeArti
0sukses — mirror kini identik dengan sumber
1opsi tidak dikenal (pemakaian salah)
2folder sumber tidak ditemukan
3rsync gagal

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:

bash
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.log

Output yang Dihasilkan:

text
== 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=0

Mari kita bedah bagian-bagian kuncinya:

  1. BASE=$(cd "$(dirname "$0")" && pwd): skrip menemukan sendiri letaknya, lalu menurunkan seluruh jalur kerja — sumber, mirror, log — dari BASE, bukan dari direktori kerja pemanggil. Inilah yang membuat skrip aman untuk scheduler: cron memanggil dari $HOME, systemd dari /, dan keduanya tetap menemukan folder otomasi yang sama.
  2. 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.sh membuat seluruh jejak eksekusi bisa direproduksi.
  3. Loop for arg in "$@" dengan case: opsi yang tidak dikenal ditolak lewat stderr dengan pesan pemakaian, bukan diabaikan diam-diam. Salah ketik --dryrun harus terdengar saat itu juga, bukan dua minggu kemudian di log.
  4. Opsi=(-a --delete --itemize-changes): array bash menjaga setiap opsi tetap satu argumen tersendiri; --dry-run cukup ditambahkan dengan Opsi+=(--dry-run). Rincian perilaku -a dan --delete dibahas tuntas di Chapter Rsync; daftar lengkap opsinya ada di rsync(1) .
  5. ubah=$(rsync ...) dan cabang if !: seluruh keluaran --itemize-changes ditangkap ke variabel ubah — rsync yang gagal (rsync keluar tidak nol) langsung menulis baris GAGAL ke log lalu keluar 3, tanpa melanjutkan ke ringkasan yang akan menyesatkan.
  6. grep -c '^[<>]f' dan grep -c '^\*deleting': itemize rsync memberi kode satu huruf per baris — >f/<f berarti berkas dipindahkan, *deleting berarti berkas dihapus dari mirror. Dua hitungan itulah isi ringkasan; pada eksekusi kedua keduanya 0, itulah bukti numeric idempotensi.
  7. Blok dry-run exit 0 sebelum penulisan log: pratinjau berhenti tepat sebelum efek apa pun terjadi — tidak ada berkas tersalin, tidak ada baris log. ls otomasi/mirror dan wc -l pada blok di atas membuktikan keduanya.
  8. printf '%s|OK|keluar=0|...' >> "$TUGAS_LOG": satu baris per eksekusi, dipisah tanda | — format terstruktur ini yang menjadi bahan baku cek-jadwal.sh pada 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
bash
#!/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
bash
#!/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
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

tests/run-tests.sh

Unduh berkas
bash
#!/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 ]