Tugas rutin jarang gagal karena sulit — ia gagal karena bergantung pada ingatan manusia. Menyalin folder catatan kerja ke salinan mirror, memeriksa hasilnya, mencatat kapan terakhir berhasil: masing-masing hanya lima menit, sampai suatu hari rapat pagi molor, tugas itu terlewat, dan tidak ada yang menyadarinya sampai mirror ternyata berisi kondisi dua minggu lalu. Proyek ini memindahkan rutinitas tersebut dari ingatan ke sistem: satu skrip tugas yang idempoten, dijadwalkan dua cara — lewat crontab user dan lewat systemd timer sebagai perbandingan nyata — lengkap dengan log terstruktur per eksekusi, kunci flock anti-dobel, dan skrip diagnosis yang membaca log dan melaporkan kesehatan jadwal.

Kerangka yang kita bangun terdiri dari empat komponen yang saling melengkapi:

  • src/tugas.sh — tugas inti: sinkronisasi rsync folder sumber ke mirror disertai ringkasan per eksekusi. Idempoten, menghitung sendiri jalur kerjanya dari letak skrip, dan mengenal opsi --dry-run serta --verbose.
  • config/tugas.crontab — jadwal untuk crontab user: environment minimal (PATH, SHELL, MAILTO=""), satu baris jadwal harian yang dibungkus flock -n dan mengarahkan output ke berkas.
  • config/tugas-sinkron.service + config/tugas-sinkron.timer — pasangan unit systemd timer untuk jadwal yang sama, dengan OnCalendar=08:00 dan Persistent=true untuk mengejar jadwal yang terlewat saat mesin mati.
  • src/cek-jadwal.sh — alat diagnosis: membaca log terstruktur, menghitung umur sejak sukses terakhir, mendeteksi kegagalan berturut-turut, lalu menyimpulkan status jadwal.

Tampilan Program Akhir #

Sesi berikut menunjukkan kerangka yang sudah terpasang pada data contoh. runner.sh berperan sebagai pengganti scheduler di lingkungan latihan, sementara cek-jadwal.sh merangkum isi log menjadi status yang bisa dibaca sekilas:

console
$ bash runner.sh .
scheduler: tugas keluar=0
$ cat log/tugas.log
20260115T080000|OK|keluar=0|durasi=0s|total=3|pindah=3|hapus=0
20260116T080000|OK|keluar=0|durasi=0s|total=3|pindah=0|hapus=0
$ NOW=20260116T120000 bash cek-jadwal.sh log/tugas.log
log      : log/tugas.log
eksekusi : 2
terakhir : OK pada 2026-01-16 08:00:00
sukses   : 2026-01-16 08:00:00 (4 jam lalu)
berturut : 0 gagal beruntun
status   : SEHAT — sukses terakhir 4 jam lalu

Dua baris log itu meringkas seluruh siklus otomasi: tugas berjalan sendiri setiap pagi, eksekusinya tercatat dalam format yang mudah diurai, dan ada alat yang membuktikan jadwal tersebut sehat — tanpa satu pun langkah manual.

Tujuan Pembelajaran #

Setelah menyelesaikan proyek ini, kita mampu:

  • merancang tugas terjadwal yang idempoten dan membuktikannya lewat diff antar eksekusi,
  • memakai --dry-run untuk pratinjau yang dipastikan tidak berefek samping,
  • menulis berkas crontab lengkap: environment minimal, flock -n, dan redirect log,
  • memvalidasi sintaks lima field jadwal crontab dengan skrip awk,
  • menulis pasangan unit service dan timer lalu memverifikasinya dengan systemd-analyze verify,
  • memilih antara cron dan systemd timer berdasarkan environment, log, dan pengejaran jadwal,
  • membaca log terstruktur untuk mendiagnosis jadwal yang macet, gagal, jalan dobel, atau terlewat.

Prasyarat #

Proyek ini tidak memperkenalkan alat baru, melainkan merangkai materi yang sudah dibahas:

Tahapan Pengerjaan #

Proyek dikerjakan dalam tiga bagian berurutan. Setiap bagian diakhiri dengan keadaan yang bisa dijalankan dan diamati:

  1. Bagian 1: Membuat Script Otomatis — menulis tugas sinkronisasi yang idempoten, mengenal argument parsing --dry-run dan --verbose, serta konvensi kode keluar.
  2. Bagian 2: Menjadwalkan Tugas — menulis berkas crontab dan membedahnya field per field, memvalidasi sintaksnya dengan awk, menulis pasangan unit systemd dan memverifikasinya, lalu membandingkan kedua pendekatan.
  3. Bagian 3: Logging dan Troubleshooting — menjalankan sesi log nyata, membangun cek-jadwal.sh untuk membacanya, dan berlatih mendiagnosis empat gejala jadwal klasik.

Mulailah dari Bagian 1 karena tugas yang akan dijadwalkan harus aman diulang lebih dulu — jadwal hanyalah mesin pengulangan, dan mesin itu akan memperbesar cacat skrip sebesar ia memperbesar kelebihannya.

Satu catatan tentang lingkungan latihan: sesi terminal pada course ini tidak menjalankan daemon cron maupun systemd timer secara aktif, sehingga pemasangan jadwal sungguhan dijalankan pada komputer Anda sendiri. Yang kita kerjakan di sini justru lapisan yang biasanya terlewat: berkas jadwal yang tertata dan tervalidasi — validator sintaks untuk crontab dan systemd-analyze verify untuk unit — serta runner.sh yang mensimulasikan pemanggilan scheduler secara deterministik sehingga seluruh perilaku bisa diuji tanpa menunggu pukul 08:00. Berkas sumber lengkap proyek (tugas.sh, cek-jadwal.sh, runner.sh, berkas jadwal di config/, dan test suite di tests/) tersedia pada bagian Berkas Sumber di bawah halaman setiap bagian, sehingga hasil pekerjaan bisa dibandingkan dengan versi referensi kapan saja.

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 ]