prompts.chatprompts.chatprompts.chat
BotPromptKỹ năngQuy trìnhDanh mụcThẻCao thủ PromptPhong cách
SáchCho trẻ emNhà phát triển
Đăng nhập
CC0 2026 prompts.chat
DeepWikiHướng dẫn...Tài liệuAPIQuyền riêng tưĐiều khoảnHỗ trợGiới thiệuGitHub
Tất cả thẻ

Prompt SRE

9 prompt
Khung Kiểm toán Bảo mật & Kiến trúc Kho mã nguồn (Repository)
Có cấu trúc

Quy trình kiểm toán kho mã nguồn (repository) dựa trên nghiên cứu thực tiễn, bao phủ OWASP Top 10, các nguyên lý SOLID, bộ chỉ số DORA và các tiêu chí sẵn sàng vận hành thực tế (production readiness) của Google SRE làm các điểm neo tri thức. Được tạo bởi prompt-forge.

1title: Khung Kiểm toán Bảo mật & Kiến trúc Kho mã nguồn (Repository)
2domain: backend,infra
3anchors:
4 - OWASP Top 10 (2021)
5 - SOLID Principles (Robert C. Martin)
6 - DORA Metrics (Forsgren, Humble, Kim)
7 - Google SRE Book (sẵn sàng cho môi trường production)
8variables:
9 repository_name: ${repository_name}
10 stack: ${stack:Auto-detect from package.json, requirements.txt, go.mod, Cargo.toml, pom.xml}
...+131 dòng nữa
Code ReviewSREPrompt Forge+4
C@caksan
0
Trình Giải Thích và Xác Thực Lịch Trình Cron
Kỹ năng

Giải thích bất kỳ biểu thức cron nào bằng ngôn ngữ tự nhiên dễ hiểu, liệt kê các thời điểm chạy tiếp theo, và cảnh báo những cạm bẫy phổ biến như quy tắc ngày-trong-tháng HOẶC thứ-trong-tuần, các ngày không bao giờ xảy ra, khoảng trống giờ mùa hè (DST), sự nhầm lẫn giữa giờ UTC và giờ địa phương, cùng các tác vụ bị chạy chồng chéo. Bao gồm công cụ kiểm tra tệp crontab viết bằng thư viện chuẩn Python đã được kiểm thử.

---
name: cron-schedule-explainer
description: Giải thích, xác thực và viết các lịch trình cron - dịch một biểu thức cron sang ngôn ngữ tự nhiên dễ hiểu, liệt kê các thời điểm chạy tiếp theo, đồng thời cảnh báo những cạm bẫy như quy tắc ngày-trong-tháng HOẶC thứ-trong-tuần, các ngày không bao giờ xảy ra, khoảng trống giờ mùa hè (DST), sự nhầm lẫn về múi giờ, và các tác vụ chạy chồng chéo hoặc quá dày đặc. Sử dụng khi người dùng dán một dòng crontab, Kubernetes CronJob, lịch chạy GitHub Actions, hoặc hỏi "khi nào tác vụ này sẽ chạy?" hay "hãy viết một biểu thức cron chạy vào thứ Ba tuần thứ hai của mỗi tháng".
---

# Trình Giải Thích Lịch Trình Cron

Bạn giúp cho các tác vụ được lập lịch trở nên dễ đoán định. Đối với mỗi lịch trình, bạn đưa ra ý nghĩa bằng ngôn ngữ tự nhiên dễ hiểu, các thời điểm chạy cụ thể tiếp theo, và những rủi ro có thể khiến ai đó bị bất ngờ lúc 2 giờ sáng.

## Các tệp trong kỹ năng này

- `scripts/cron_explain.py` - trình phân tích cú pháp, giải thích, tính toán lần chạy tiếp theo và kiểm tra cạm bẫy (chỉ dùng thư viện chuẩn Python 3)
- `references/cron-syntax.md` - phạm vi các trường, ký tự đặc biệt, các macro và sự khác biệt giữa các nền tảng
- `references/scheduling-pitfalls.md` - những sai lầm phổ biến và cách phòng tránh chúng
- `templates/schedule-review.md` - định dạng báo cáo rà soát
- `examples/example-backup-review.md` - bài rà soát mẫu hoàn chỉnh cho ba dòng crontab

## Quy trình làm việc

### 1. Xác định nền tảng
Chuẩn cron 5 trường (Vixie cron, cronie, Kubernetes CronJob, GitHub Actions) là mặc định. Hãy hỏi hoặc kiểm tra xem liệu người dùng có đang nói đến Quartz (6 hoặc 7 trường có thêm giây và ký tự `?`), AWS EventBridge (6 trường có thêm năm), hay systemd timers hay không; xem chi tiết trong `references/cron-syntax.md`. Lưu ý về múi giờ: GitHub Actions luôn sử dụng múi giờ UTC; Kubernetes sử dụng múi giờ của trình điều khiển (controller) trừ khi trường `timeZone` được thiết lập.

### 2. Chạy công cụ kiểm tra
```bash
python3 scripts/cron_explain.py "30 2 * * 1-5"
python3 scripts/cron_explain.py "0 9 1 * MON" --count 8 --from "2026-10-08 10:00"
python3 scripts/cron_explain.py --file crontab.txt
```
Kịch bản sẽ in ra lời giải thích bằng ngôn ngữ dễ hiểu, N thời điểm chạy tiếp theo (theo giờ địa phương không gắn múi giờ của máy chủ), và các cảnh báo. Mã thoát (exit code) là 1 khi một biểu thức không hợp lệ hoặc không bao giờ chạy.

Nếu bạn không thể chạy kịch bản, hãy tự áp dụng đúng các quy tắc tương tự một cách thủ công và nói rõ điều đó.

### 3. Giải thích
Đối với mỗi lịch trình, hãy cung cấp:
1. Ý nghĩa bằng một câu ngôn ngữ tự nhiên dễ hiểu.
2. Từ 3 đến 5 lần chạy tiếp theo kèm theo múi giờ được nêu rõ.
3. Các cảnh báo từ kịch bản và từ tệp `references/scheduling-pitfalls.md` có liên quan (chạy chồng chéo với các tác vụ kéo dài, giờ mùa hè DST, giờ UTC so với giờ địa phương, các lần chạy bị bỏ lỡ khi máy tắt).

### 4. Viết hoặc sửa lịch trình
Khi người dùng mô tả một lịch trình bằng lời, hãy viết biểu thức cron tương ứng, sau đó đưa nó qua công cụ kiểm tra để xác nhận rằng các lần chạy tiếp theo khớp đúng với ý định của họ. Đối với những lịch trình mà cron không thể diễn đạt trực tiếp (chẳng hạn như thứ Ba tuần thứ hai của tháng, hoặc ngày làm việc cuối cùng trong tháng), hãy đưa ra một biểu thức cron kết hợp với một điều kiện bảo vệ (guard) trong câu lệnh, ví dụ như `[ "$(date +\%d)" -le 07 ] && run-job`, và giải thích rõ lý do tại sao.

### 5. Báo cáo
Sử dụng mẫu `templates/schedule-review.md`, giống như cách làm trong `examples/example-backup-review.md`.

## Quy tắc
- Luôn nêu rõ múi giờ mà bạn đang giả định.
- Hãy nhớ rằng ký tự `%` bắt buộc phải được thoát (escape) thành `\%` bên trong phần câu lệnh của tệp crontab.
- Tuyệt đối không trực tiếp chỉnh sửa tệp crontab đang chạy của người dùng; hãy hiển thị dòng cần thêm vào và hướng dẫn bước chạy `crontab -e`.
- Luôn khuyến nghị sử dụng cơ chế khóa (ví dụ: `flock -n /tmp/job.lock cmd`) bất cứ khi nào một tác vụ có khả năng chạy lâu hơn khoảng thời gian lặp lại của nó.
FILE:references/cron-syntax.md
# Tài Liệu Tham Chiếu Cú Pháp Cron (Chuẩn 5 trường)

```
+------------- phút (0-59)
| +----------- giờ (0-23)
| | +--------- ngày trong tháng (1-31)
| | | +------- tháng (1-12 hoặc JAN-DEC)
| | | | +----- thứ trong tuần (0-7 hoặc SUN-SAT; cả 0 và 7 đều là Chủ nhật)
| | | | |
* * * * *  command
```

## Các ký tự đặc biệt
| Ký hiệu | Ý nghĩa | Ví dụ |
|---|---|---|
| `*` | mọi giá trị | `* * * * *` mỗi phút |
| `,` | danh sách | `0 8,12,18 * * *` vào lúc 08:00, 12:00, 18:00 |
| `-` | khoảng giá trị | `0 9 * * 1-5` 09:00 từ thứ Hai đến thứ Sáu |
| `/` | bước nhảy | `*/15 * * * *` mỗi 15 phút; `10-50/20` = 10, 30, 50 |

Tên tháng và thứ không phân biệt chữ hoa chữ thường. Khoảng tên viết tắt (`MON-FRI`) hoạt động trên hầu hết các bản triển khai cron, còn danh sách các tên viết tắt thì hoạt động ở mọi nơi.

## Các macro
| Macro | Biểu thức tương đương |
|---|---|
| `@yearly` / `@annually` | `0 0 1 1 *` |
| `@monthly` | `0 0 1 * *` |
| `@weekly` | `0 0 * * 0` |
| `@daily` / `@midnight` | `0 0 * * *` |
| `@hourly` | `0 * * * *` |
| `@reboot` | chạy một lần khi khởi động máy (không dựa theo thời gian) |

## Quy tắc kết hợp trường ngày
Nếu cả trường ngày trong tháng và trường thứ trong tuần đều bị giới hạn (tức là không trường nào mang dấu `*`), tác vụ sẽ chạy khi MỘT TRONG HAI điều kiện khớp. Biểu thức `0 9 1 * MON` sẽ chạy vào ngày mùng 1 hàng tháng VÀ vào mọi ngày thứ Hai.

## Sự khác biệt giữa các nền tảng
| Nền tảng | Số trường | Múi giờ | Ghi chú |
|---|---|---|---|
| Linux cron (cronie, Vixie) | 5 | giờ địa phương của hệ thống | biến `CRON_TZ=` được cronie hỗ trợ |
| Kubernetes CronJob | 5 | múi giờ của controller, hoặc `spec.timeZone` | sử dụng `concurrencyPolicy: Forbid` để ngăn chạy chồng chéo |
| GitHub Actions `schedule` | 5 | luôn là UTC | các lần chạy có thể bị trễ khi hệ thống quá tải; khoảng cách tối thiểu là 5 phút |
| Quartz (Java) | 6-7 (giây đứng đầu, năm là tùy chọn) | có thể cấu hình | dùng `?` cho "không chỉ định giá trị cụ thể", hỗ trợ `L`, `W`, `#` |
| AWS EventBridge | 6 (có trường năm) | UTC trừ khi múi giờ của bộ lập lịch được thiết lập | một trong hai trường ngày-trong-tháng hoặc thứ-trong-tuần bắt buộc phải là `?` |

Kịch bản trong kỹ năng này hỗ trợ chuẩn 5 trường cùng các macro nêu trên (ngoại trừ `@reboot`, được kịch bản báo cáo là không dựa theo thời gian).
FILE:references/scheduling-pitfalls.md
# Những Cạm Bẫy Khi Lập Lịch

## 1. Ngày trong tháng HOẶC thứ trong tuần
Biểu thức `0 0 13 * 5` KHÔNG PHẢI là "thứ Sáu ngày 13". Nó sẽ chạy vào mọi ngày 13 hàng tháng và vào mọi ngày thứ Sáu. Hãy sử dụng `0 0 13 * *` kết hợp với một điều kiện bảo vệ: `[ "$(date +\%u)" = 5 ] && cmd`.

## 2. những ngày không bao giờ hoặc hiếm khi xảy ra
- `0 0 30 2 *` không bao giờ chạy (tháng Hai không có ngày 30).
- `0 0 31 * *` chỉ chạy trong 7 tháng có 31 ngày của năm.
- `0 0 29 2 *` chỉ chạy vào các năm nhuận.
Đối với nhu cầu chạy vào "ngày cuối cùng của tháng", hãy dùng `0 0 28-31 * *` kèm điều kiện bảo vệ: `[ "$(date -d tomorrow +\%d)" = 01 ] && cmd`.

## 3. Giờ tiết kiệm ánh sáng ban ngày / Giờ mùa hè (DST)
Ở các múi giờ địa phương có áp dụng DST, các mốc thời gian trong khoảng từ 01:00 đến 03:00 có thể bị bỏ qua (khi vặn đồng hồ tiến lên vào mùa xuân) hoặc bị chạy hai lần (khi vặn đồng hồ lùi lại vào mùa thu), tùy thuộc vào bản triển khai cron. Hãy xếp lịch cho các tác vụ quan trọng nằm ngoài khung giờ đó, hoặc cho cron chạy theo múi giờ UTC.

## 4. Giờ UTC so với giờ địa phương
GitHub Actions và nhiều bộ lập lịch trên đám mây sử dụng giờ UTC. Lịch chạy "Mỗi ngày vào lúc 09:00" đối với một nhóm làm việc tại Istanbul (UTC+3) sẽ là `0 6 * * *` theo giờ UTC. Hãy luôn ghi chú rõ múi giờ ngay bên cạnh biểu thức cron trong tài liệu và trong bình luận mã nguồn (code comments).

## 5. Chạy quá dày đặc hoặc chạy chồng chéo
- `* * * * *` chạy tới 1440 lần mỗi ngày. Hãy đảm bảo đó thực sự là điều bạn muốn.
- Trường phút để là `*` đi kèm với một giờ cố định (`* 3 * * *`) sẽ chạy 60 lần liên tục từ 03:00 đến 03:59; thông thường ý định thực sự của người viết là `0 3 * * *`.
- Nếu một tác vụ có thể mất nhiều thời gian thực thi hơn khoảng cách giữa các lần chạy của nó, hãy sử dụng cơ chế khóa (`flock -n`) hoặc `concurrencyPolicy: Forbid`.

## 6. Giá trị bước nhảy (step) không chia chẵn vòng lặp
Biểu thức `*/7` ở trường phút sẽ chạy vào các phút 0, 7, ..., 56, rồi chạy lại ngay ở phút 0 của giờ tiếp theo (chỉ cách nhau 4 phút). Biểu thức `*/25` chạy vào các phút 0, 25, 50. Các bước nhảy luôn bắt đầu đếm lại từ đầu sau mỗi giờ, mỗi ngày hoặc mỗi tháng.

## 7. Hiệu ứng bầy đàn (Thundering herd)
Rất nhiều nhóm cùng chọn `0 0 * * *` hoặc `0 * * * *`. Hãy dịch chuyển các tác vụ sang một số phút lẻ (ví dụ: `17 2 * * *`) để tránh gây đột biến tải trên các hệ thống dùng chung và các API có giới hạn tần suất gọi (rate-limited).

## 8. Biến môi trường và kết quả đầu ra
Cron chạy với biến môi trường PATH tối thiểu và không có login shell. Hãy sử dụng các đường dẫn tuyệt đối, khai báo các biến cần thiết ngay trong tệp crontab, và chuyển hướng kết quả đầu ra (`>> /var/log/job.log 2>&1`) để có thể nhìn thấy lỗi khi tác vụ thất bại. Hãy thoát ký tự `%` thành `\%`.

## 9. Các lần chạy bị bỏ lỡ
Cron thuần túy không tự động chạy bù các lần thực thi bị bỏ lỡ trong lúc máy tính đang tắt. Hãy sử dụng anacron, systemd timers với thiết lập `Persistent=true`, hoặc `startingDeadlineSeconds` của Kubernetes khi việc bỏ lỡ một lần chạy gây ảnh hưởng quan trọng.
FILE:templates/schedule-review.md
# Báo Cáo Rà Soát Lịch Trình: {{system_or_repo}}

**Nền tảng:** {{Linux cron | Kubernetes CronJob | GitHub Actions | other}}
**Múi giờ giả định:** {{time_zone}}
**Ngày rà soát:** {{date}}

## Tóm tắt
{{One or two sentences: are the schedules doing what the team expects, and what must change.}}

## Các lịch trình

### {{n}}. `{{expression}}` - {{job name}}
- **Ý nghĩa:** {{plain-English explanation}}
- **Các lần chạy tiếp theo:** {{run 1}}, {{run 2}}, {{run 3}}
- **Kết luận:** {{OK | FIX | CLARIFY}}
- **Cảnh báo:**
  - {{warning}}
- **Dòng cấu hình đề xuất:**
  ```
  {{corrected crontab line}}
  ```

## Câu hỏi
- {{question for the team}}
FILE:examples/example-backup-review.md
# Báo Cáo Rà Soát Lịch Trình: crontab của máy chủ vận hành (ops server)

**Nền tảng:** Linux cron (cronie)
**Múi giờ giả định:** Europe/Berlin (giờ địa phương của máy chủ, có áp dụng DST)
**Ngày rà soát:** 2026-10-08

## Tóm tắt
Hai trong số ba dòng cấu hình không hoạt động đúng như nội dung ghi chú. Tác vụ sao lưu chạy bên trong khung giờ chuyển đổi giờ mùa hè (DST), còn báo cáo "thứ Sáu ngày 13" thực chất lại chạy vào mọi ngày thứ Sáu và mọi ngày 13 hàng tháng.

## Các lịch trình

### 1. `30 2 * * *` - sao lưu cơ sở dữ liệu hàng đêm
- **Ý nghĩa:** Vào lúc 02:30 mỗi ngày.
- **Các lần chạy tiếp theo:** 2026-10-09 02:30, 2026-10-10 02:30, 2026-10-11 02:30
- **Kết luận:** FIX
- **Cảnh báo:**
  - Mốc 02:30 nằm trong khung giờ thay đổi DST; vào đêm chỉnh đồng hồ tiến lên trong mùa xuân, tác vụ có thể bị bỏ qua, còn vào mùa thu nó có thể bị chạy hai lần.
  - Quá trình sao lưu có thể mất hơn một giờ vào dịp cuối tháng; hiện chưa có cơ chế khóa (lock).
- **Dòng cấu hình đề xuất:**
  ```
  17 4 * * * flock -n /tmp/db-backup.lock /opt/scripts/db-backup.sh >> /var/log/db-backup.log 2>&1
  ```

### 2. `0 9 13 * FRI` - báo cáo vui "thứ Sáu ngày 13"
- **Ý nghĩa:** Vào lúc 09:00 ngày 13 của tháng HOẶC vào mọi ngày thứ Sáu (theo quy tắc kết hợp ngày của cron).
- **Các lần chạy tiếp theo:** 2026-10-09 09:00 (Thứ Sáu), 2026-10-13 09:00 (Thứ Ba, ngày 13), 2026-10-16 09:00 (Thứ Sáu)
- **Kết luận:** FIX
- **Cảnh báo:**
  - Cả hai trường ngày đều bị giới hạn, vì vậy cron áp dụng phép toán HOẶC (OR) chứ không phải VÀ (AND).
- **Dòng cấu hình đề xuất:**
  ```
  0 9 13 * * [ "$(date +\%u)" = 5 ] && /opt/scripts/fun-report.sh
  ```

### 3. `*/20 8-18 * * 1-5` - đồng bộ phiếu yêu cầu (tickets) từ hệ thống hỗ trợ
- **Ý nghĩa:** Mỗi 20 phút một lần (vào các phút :00, :20, :40) từ 08:00 đến 18:59, từ thứ Hai đến thứ Sáu.
- **Các lần chạy tiếp theo:** 2026-10-08 10:20, 2026-10-08 10:40, 2026-10-08 11:00
- **Kết luận:** CLARIFY
- **Cảnh báo:**
  - Lần chạy cuối cùng trong ngày là lúc 18:40 chứ không phải 18:00. Hãy sử dụng `8-17` kết hợp với một dòng `0 18 * * 1-5` riêng biệt nếu quá trình đồng bộ cần dừng lại đúng vào lúc 18:00.

## Câu hỏi
- Máy chủ có nên chạy cron theo múi giờ UTC để tránh hoàn toàn các rắc rối liên quan đến giờ mùa hè (DST) hay không?
FILE:scripts/cron_explain.py
#!/usr/bin/env python3
"""Explain, validate, and preview standard 5-field cron expressions (stdlib only).

Usage:
  python3 cron_explain.py "EXPR" [--count N] [--from "YYYY-MM-DD HH:MM"]
  python3 cron_explain.py --file crontab.txt [--count N] [--from ...]

For each expression: a plain-English explanation, the next N run times
(naive server-local time), and pitfall warnings. In --file mode, crontab
lines are read; comments, blank lines and VAR=value lines are skipped and the
first five fields (or a leading @macro) are taken as the schedule.
Exit code: 0 = all valid, 1 = an expression is invalid or never runs, 2 = usage.
"""
import argparse
import calendar
import datetime as dt
import sys

MONTHS = {m.lower(): i for i, m in enumerate(calendar.month_abbr) if m}
DAYS = {"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}
MACROS = {
    "@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *",
    "@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@midnight": "0 0 * * *",
    "@hourly": "0 * * * *",
}
FIELDS = [("minute", 0, 59, {}), ("hour", 0, 23, {}), ("day of month", 1, 31, {}),
          ("month", 1, 12, MONTHS), ("day of week", 0, 7, DAYS)]
DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]


def compress(values, fmt=str):
    """[1,2,3,5] -> '1-3, 5' using fmt for each number."""
    vals, out, i = sorted(values), [], 0
    while i < len(vals):
        j = i
        while j + 1 < len(vals) and vals[j + 1] == vals[j] + 1:
            j += 1
        out.append(fmt(vals[i]) if j - i < 2 else f"{fmt(vals[i])}-{fmt(vals[j])}")
        if 0 < j - i < 2:
            out.append(fmt(vals[j]))
        i = j + 1
    return ", ".join(out)


class CronError(ValueError):
    pass


def _num(token, lo, hi, names, field):
    t = token.lower()
    if t in names:
        return names[t]
    if not t.isdigit():
        raise CronError(f"{field}: '{token}' is not a number or known name")
    v = int(t)
    if not lo <= v <= hi:
        raise CronError(f"{field}: {v} is outside {lo}-{hi}")
    return v


def parse_field(text, lo, hi, names, field):
    values = set()
    for part in text.split(","):
        if not part:
            raise CronError(f"{field}: empty list item in '{text}'")
        step = 1
        if "/" in part:
            part, step_s = part.split("/", 1)
            if not step_s.isdigit() or int(step_s) == 0:
                raise CronError(f"{field}: bad step '/{step_s}'")
            step = int(step_s)
        if part == "*":
            start, end = lo, hi
        elif "-" in part:
            a, b = part.split("-", 1)
            start, end = _num(a, lo, hi, names, field), _num(b, lo, hi, names, field)
            if start > end:
                raise CronError(f"{field}: range {a}-{b} is reversed")
        else:
            start = _num(part, lo, hi, names, field)
            end = hi if step > 1 else start
        values.update(range(start, end + 1, step))
    return values


def parse(expr):
    expr = expr.strip()
    if expr.lower() == "@reboot":
        raise CronError("@reboot runs once at startup and is not time based")
    expr = MACROS.get(expr.lower(), expr)
    parts = expr.split()
    if len(parts) != 5:
        hint = " (6-7 fields look like Quartz or EventBridge; see references/cron-syntax.md)" if len(parts) in (6, 7) else ""
        raise CronError(f"expected 5 fields, got {len(parts)}{hint}")
    for p in parts:
        bare = p.lower()
        for n in list(MONTHS) + list(DAYS):
            bare = bare.replace(n, "")
        if any(c in bare for c in "?lw#"):
            raise CronError(f"'{p}': '?', 'L', 'W' and '#' are Quartz extensions, not standard cron")
    sets = [parse_field(p, lo, hi, names, name) for p, (name, lo, hi, names) in zip(parts, FIELDS)]
    if 7 in sets[4]:
        sets[4].discard(7)
        sets[4].add(0)
    return parts, sets


def describe_set(values, lo, hi, field, raw):
    vals = sorted(values)
    if raw == "*":
        return None
    if field == "day of week":
        names = [DAY_NAMES[v] for v in vals]
        if vals == [1, 2, 3, 4, 5]:
            return "Monday to Friday"
        if vals == [0, 6]:
            return "on weekends"
        return ", ".join(names)
    if field == "month":
        return ", ".join(calendar.month_name[v] for v in vals)
    return compress(vals)


def explain(parts, sets):
    minute, hour, dom, month, dow = sets
    rm, rh, rdom, rmon, rdow = parts
    if rm == "*" and rh == "*":
        time_txt = "every minute"
    elif rm.startswith("*/") and rh == "*":
        time_txt = f"every {rm[2:]} minutes"
    elif rm.startswith("*/"):
        time_txt = (f"every {rm[2:]} minutes (at minute " + ", ".join(str(m) for m in sorted(minute)) +
                    ") during hour(s) " + compress(hour, lambda h: f"{h:02d}"))
    elif rh == "*":
        time_txt = "at minute " + ", ".join(str(m) for m in sorted(minute)) + " of every hour"
    elif rm == "*":
        time_txt = "every minute during hour(s) " + compress(hour, lambda h: f"{h:02d}")
    elif len(minute) * len(hour) <= 6:
        time_txt = "at " + ", ".join(f"{h:02d}:{m:02d}" for h in sorted(hour) for m in sorted(minute))
    else:
        time_txt = ("at minute(s) " + ", ".join(str(m) for m in sorted(minute)) +
                    " past hour(s) " + compress(hour, lambda h: f"{h:02d}"))
    day_txt = []
    d_dom = describe_set(dom, 1, 31, "day of month", rdom)
    d_dow = describe_set(dow, 0, 6, "day of week", rdow)
    if d_dom and d_dow:
        day_txt.append(f"on day(s) {d_dom} of the month OR on {d_dow}")
    elif d_dom:
        day_txt.append(f"on day(s) {d_dom} of the month")
    elif d_dow:
        day_txt.append(d_dow if d_dow.startswith("on ") else f"on {d_dow}")
    else:
        day_txt.append("every day")
    d_mon = describe_set(month, 1, 12, "month", rmon)
    if d_mon:
        day_txt.append(f"in {d_mon}")
    text = f"{time_txt}, {' '.join(day_txt)}"
    return text[0].upper() + text[1:] + "."


def day_matches(d, parts, sets):
    _, _, dom, month, dow = sets
    if d.month not in month:
        return False
    cron_dow = (d.weekday() + 1) % 7
    dom_r, dow_r = parts[2] != "*", parts[4] != "*"
    if dom_r and dow_r:
        return d.day in dom or cron_dow in dow
    if dom_r:
        return d.day in dom
    if dow_r:
        return cron_dow in dow
    return True


def next_runs(parts, sets, start, count, max_days=366 * 8):
    minute, hour = sorted(sets[0]), sorted(sets[1])
    runs = []
    day = start.date()
    for _ in range(max_days):
        if day_matches(day, parts, sets):
            for h in hour:
                for m in minute:
                    t = dt.datetime(day.year, day.month, day.day, h, m)
                    if t > start:
                        runs.append(t)
                        if len(runs) >= count:
                            return runs
        day += dt.timedelta(days=1)
    return runs


def warnings(parts, sets, runs):
    minute, hour, dom, month, dow = sets
    out = []
    if parts[2] != "*" and parts[4] != "*":
        out.append("Day of month AND day of week are both set: cron runs when EITHER matches (OR, not AND).")
    if parts[2] != "*" and parts[4] == "*":
        max_days = {m: (29 if m == 2 else calendar.monthrange(2026, m)[1]) for m in month}
        if not any(d <= max_days[m] for m in month for d in dom):
            out.append("Never runs: the chosen day(s) of month do not exist in the chosen month(s).")
        elif any(d > 28 for d in dom):
            out.append("Some chosen days (29-31) do not exist in every month, so some months are skipped.")
    if parts[0] == "*" and parts[1] != "*":
        out.append("Minute is '*': runs every minute of the chosen hour(s); did you mean minute 0?")
    runs_per_day = len(minute) * len(hour)
    if runs_per_day >= 288:
        out.append(f"Runs {runs_per_day} times a day; make sure that is intended and add a lock against overlap.")
    if any(1 <= h <= 2 for h in hour) and parts[1] != "*":
        out.append("Runs between 01:00 and 02:59: in local time zones with DST this can be skipped or run twice.")
    for i, raw in ((0, parts[0]), (1, parts[1])):
        if "/" in raw:
            step = int(raw.split("/")[1])
            span = 60 if i == 0 else 24
            if span % step:
                out.append(f"Step /{step} does not divide {span}: the gap is uneven where the {'hour' if i == 0 else 'day'} wraps.")
    if parts[0] == "0" and parts[1] in ("*", "0"):
        out.append("Minute 0 at the top of the hour is a popular slot; consider an odd minute to avoid load spikes.")
    return out


def check(expr, start, count):
    print(f"Expression: {expr}")
    try:
        parts, sets = parse(expr)
    except CronError as e:
        print(f"  INVALID: {e}\n")
        return False
    print(f"  Meaning: {explain(parts, sets)}")
    runs = next_runs(parts, sets, start, count)
    ok = True
    if runs:
        print(f"  Next {len(runs)} run(s) after {start:%Y-%m-%d %H:%M} (server local time):")
        for r in runs:
            print(f"    {r:%Y-%m-%d %H:%M} {r:%a}")
    else:
        print("  Next runs: none found in the next 8 years")
        ok = False
    for w in warnings(parts, sets, runs):
        print(f"  WARNING: {w}")
    print()
    return ok


def crontab_schedules(path):
    with open(path, encoding="utf-8") as f:
        for line in f:
            s = line.strip()
            if not s or s.startswith("#"):
                continue
            first = s.split()[0]
            if "=" in first and not first.startswith("@"):
                continue
            yield first if first.startswith("@") else " ".join(s.split()[:5])


def main(argv=None):
    ap = argparse.ArgumentParser(description="Explain and validate cron expressions.")
    ap.add_argument("expr", nargs="?", help='cron expression in quotes, e.g. "*/15 9-17 * * 1-5"')
    ap.add_argument("--file", help="read schedules from a crontab file")
    ap.add_argument("--count", type=int, default=5, help="number of next runs to show (default 5)")
    ap.add_argument("--from", dest="start", help='start time "YYYY-MM-DD HH:MM" (default: now)')
    a = ap.parse_args(argv)
    if bool(a.expr) == bool(a.file):
        ap.print_usage(sys.stderr)
        print("error: give exactly one of EXPR or --file", file=sys.stderr)
        return 2
    try:
        start = dt.datetime.strptime(a.start, "%Y-%m-%d %H:%M") if a.start else dt.datetime.now().replace(second=0, microsecond=0)
    except ValueError:
        print("error: --from must look like 2026-10-08 10:00", file=sys.stderr)
        return 2
    exprs = list(crontab_schedules(a.file)) if a.file else [a.expr]
    results = [check(e, start, max(1, a.count)) for e in exprs]
    print(f"{sum(results)} of {len(results)} schedule(s) valid and runnable.")
    return 0 if all(results) else 1


if __name__ == "__main__":
    sys.exit(main())
DevOpsDebuggingSRE+2
F@f
0
Công Cụ Tạo Khung Tài Liệu Trực Ca Vận Hành (On-Call Runbook Scaffold) (YAML)
Văn bản

Mô tả một dịch vụ, kiến trúc của nó, chỉ số SLO, các sự cố thường gặp, bảng điều khiển (dashboards), quy trình triển khai (deploy) và đầu mối liên hệ chuyển cấp (escalation) để nhận khung tài liệu hướng dẫn trực ca (on-call runbook) dưới dạng YAML: các cấp độ nghiêm trọng, tín hiệu phát hiện kèm gợi ý ngưỡng cảnh báo, quy trình phân loại xử lý trong 15 phút đầu, kịch bản giảm thiểu tác động cho từng sự cố, quy trình quay lui (rollback) và ngắt khẩn cấp cờ tính năng (feature flag kill switch), mẫu thông báo trang trạng thái và kênh nội bộ, lộ trình chuyển cấp hỗ trợ và danh sách kiểm tra sau sự cố.

role: >
  Bạn là một kỹ sư độ tin cậy hệ thống cấp cao (Senior Site Reliability Engineer - SRE) đã từng biên soạn và duy trì
  tài liệu hướng dẫn trực ca (on-call runbooks) cho các dịch vụ web, API, đường ống dữ liệu (data pipelines) và các tiến trình nền (background
  workers). Bạn viết runbook dành cho một kỹ sư đang mệt mỏi bị gọi dậy lúc 3 giờ sáng: các bước ngắn gọn,
  có thứ tự rõ ràng, chỉ đích danh nơi cần kiểm tra, ưu tiên các biện pháp giảm thiểu rủi ro an toàn trước tiên, và các quy tắc
  rõ ràng về thời điểm cần chuyển cấp hỗ trợ (escalate). Bạn không bao giờ tự bịa ra tên dashboard, câu lệnh hoặc
  đầu mối liên hệ mà người dùng không cung cấp; thay vào đó, bạn để lại các nhãn giữ chỗ (placeholders) được đánh dấu rõ ràng.

task: >
  Xây dựng bộ khung tài liệu trực ca (on-call runbook scaffold) cho dịch vụ được mô tả bên dưới:
  các cấp độ nghiêm trọng, tín hiệu phát hiện và cảnh báo, luồng phân loại xử lý ban đầu (triage flow), các kịch bản
  giảm thiểu (mitigation playbooks) cho từng sự cố phổ biến, quy trình quay lui (rollback), các mẫu thông báo
  giao tiếp, lộ trình chuyển cấp (escalation path), và danh sách kiểm tra sau sự cố, sẵn sàng để
  dán vào tài liệu của nhóm và điền thông tin chi tiết vào.

inputs:
  service_name: "checkout-api"
  what_it_does: "handles cart checkout and payment authorization for the web shop and mobile app"
  architecture: "stateless Node.js service on Kubernetes, 6 pods, PostgreSQL primary with one replica, Redis cache, calls an external payment provider"
  traffic_and_slo: "about 40 requests per second at peak, 99.9 percent availability and p95 latency under 400 ms"
  common_incidents: "payment provider timeouts, database connection pool exhaustion, bad deploys, Redis eviction storms, certificate expiry"
  dashboards_and_tools: "Grafana dashboard 'Checkout Overview', Loki logs, PagerDuty, Argo CD for deploys, status page on Statuspage"
  deploy_and_rollback: "Argo CD sync from main, roughly 10 deploys a week, feature flags in LaunchDarkly"
  escalation_contacts: "primary on-call (payments team), secondary on-call, payments team lead, database on-call from platform team, payment provider support line"
  comms_channels: "#inc-checkout Slack channel, customer support lead, public status page"
  business_hours_and_timezone: "Europe/Berlin, 24x7 on-call, weekly rotation"
  known_gaps: "no alert on certificate expiry yet, replica lag is not graphed"

instructions:
  - Nêu rõ các giả định ở những nơi thiếu thông tin đầu vào thay vì đặt câu hỏi ngược lại.
  - Định nghĩa 4 cấp độ nghiêm trọng (SEV1 đến SEV4) bao gồm mức độ ảnh hưởng đến khách hàng, ví dụ cụ thể đối với dịch vụ này, thời gian phản hồi, ai sẽ nhận cảnh báo gọi trực (paged), và việc trang trạng thái (status page) có cần cập nhật hay không.
  - Đối với khâu phát hiện sự cố, hãy liệt kê từng cảnh báo hoặc tín hiệu kèm theo mã giữ chỗ cho câu truy vấn metric hoặc log, gợi ý ngưỡng cảnh báo gắn liền với SLO, các nguyên nhân có khả năng cao, và bảng điều khiển (dashboard panel) đầu tiên cần mở lên.
  - Viết một luồng phân loại xử lý chung (generic triage flow) gồm tối đa 10 bước theo thứ tự cho 15 phút đầu tiên, bắt đầu từ việc xác nhận đã nhận cảnh báo (acknowledging the page) và xác nhận phạm vi ảnh hưởng.
  - Viết một kịch bản giảm thiểu (mitigation playbook) cho mỗi sự cố thường gặp gồm: triệu chứng, các bước kiểm tra nhanh, các biện pháp giảm thiểu an toàn được sắp xếp theo thứ tự từ ít rủi ro nhất đến nhiều rủi ro nhất, khi nào cần chuyển cấp (escalate), và cách xác nhận hệ thống đã phục hồi.
  - Viết quy trình quay lui (rollback procedure) cho một bản triển khai (deploy) bị lỗi, bao gồm cách tìm phiên bản hoạt động tốt gần nhất, cách thực hiện rollback, cách tắt cờ tính năng (feature flag), và những gì cần kiểm tra xác minh sau đó.
  - Cung cấp các mẫu thông báo ngắn gọn để mở đầu kênh xử lý sự cố (incident channel kickoff), bản cập nhật mỗi 30 phút, thông báo trên trang trạng thái (đang điều tra, đã xác định nguyên nhân, đang theo dõi, đã giải quyết), và ghi chú gửi cho bộ phận hỗ trợ khách hàng.
  - Đưa ra lộ trình chuyển cấp (escalation path) kèm theo điều kiện kích hoạt và giới hạn thời gian, sử dụng các vai trò mà tôi đã cung cấp.
  - Bổ sung danh sách kiểm tra sau sự cố (dòng thời gian, số liệu ảnh hưởng, các ticket theo dõi tiếp theo, tinh chỉnh cảnh báo, cập nhật runbook) và chương trình họp rút kinh nghiệm không đổ lỗi (blameless review agenda).
  - Chuyển các lỗ hổng đã biết (known gaps) thành các công việc cần theo dõi tiếp theo (follow-up tasks) kèm theo vai trò người phụ trách.
  - Sử dụng các dấu <PLACEHOLDER> cho mọi đường dẫn liên kết, câu truy vấn, câu lệnh hoặc tên gọi mà nhóm phải tự điền vào; không bao giờ tự bịa ra chúng.

output_format: Chỉ xuất YAML, không có văn xuôi bên ngoài YAML, sử dụng chính xác cấu trúc này

output_schema:
  assumptions: [string]
  service:
    name: string
    purpose: string
    dependencies: [string]
    slo: string
    owners: string
  severity_levels:
    - level: SEV1 | SEV2 | SEV3 | SEV4
      customer_impact: string
      examples: [string]
      response_time: string
      page: [string]
      status_page_update: boolean
  detection_signals:
    - name: string
      signal: string
      query_placeholder: string
      threshold_idea: string
      likely_causes: [string]
      first_panel_to_open: string
  triage_steps:
    - step: number
      action: string
      look_at: string
      decision: string
  playbooks:
    - incident: string
      symptoms: [string]
      quick_checks: [string]
      mitigations:
        - order: number
          action: string
          risk: low | medium | high
          command_placeholder: string
      escalate_when: string
      confirm_recovery: [string]
  rollback:
    find_last_good_version: [string]
    steps: [string]
    feature_flag_kill_switch: [string]
    verify_after: [string]
  comms_templates:
    incident_channel_kickoff: string
    update_every_30_min: string
    status_page:
      investigating: string
      identified: string
      monitoring: string
      resolved: string
    customer_support_note: string
  escalation_path:
    - role: string
      when: string
      after_minutes: number
      how_to_reach: string
  post_incident_checklist: [string]
  blameless_review_agenda: [string]
  follow_up_tasks:
    - task: string
      owner_role: string
      priority: high | medium | low
Best PracticesDevOpsYAML+3
F@f
0
Công cụ Kiểm tra Khoảng trống và Điểm bất thường trong Chuỗi thời gian
Kỹ năng

Kiểm tra dữ liệu chuỗi thời gian dạng CSV (số liệu đo lường, chỉ số cảm biến, doanh số theo giờ) để phát hiện các vấn đề dữ liệu trước khi vẽ biểu đồ hoặc thiết lập cảnh báo: thiếu khoảng thời gian, dấu thời gian (timestamp) bị trùng lặp hoặc sai thứ tự, giá trị trống, đường đi ngang (flatlines) do trình xuất dữ liệu bị treo, các điểm đột biến (spikes) phát hiện qua trung vị/MAD bền vững và hiện tượng đặt lại bộ đếm (counter resets). Sử dụng khi bảng điều khiển (dashboard) có dấu hiệu bất thường, trước khi huấn luyện mô hình dự báo hoặc khi cần trả lời câu hỏi "dữ liệu này đã đầy đủ chưa?". Bao gồm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.

---
name: time-series-gap-anomaly-checker
description: Kiểm tra dữ liệu chuỗi thời gian dạng CSV (số liệu đo lường, chỉ số cảm biến, doanh số theo giờ) để phát hiện các vấn đề dữ liệu trước khi vẽ biểu đồ hoặc thiết lập cảnh báo: thiếu khoảng thời gian, dấu thời gian (timestamp) bị trùng lặp hoặc sai thứ tự, giá trị trống, đường đi ngang (flatlines) do trình xuất dữ liệu bị treo, các điểm đột biến (spikes) phát hiện qua trung vị/MAD bền vững và hiện tượng đặt lại bộ đếm (counter resets). Sử dụng khi bảng điều khiển (dashboard) có dấu hiệu bất thường, trước khi huấn luyện mô hình dự báo hoặc khi cần trả lời câu hỏi "dữ liệu này đã đầy đủ chưa?". Bao gồm công cụ kiểm tra bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---

# Công cụ Kiểm tra Khoảng trống và Điểm bất thường trong Chuỗi thời gian

Bạn giúp các chuyên viên phân tích, kỹ sư SRE và kỹ sư dữ liệu quyết định xem một chuỗi thời gian có đáng tin cậy hay không. Bạn tìm ra những vị trí dữ liệu bị thiếu, lặp lại, bị treo cứng hoặc đáng ngờ, giải thích nguyên nhân khả dĩ của từng vấn đề, đồng thời tách biệt các sự cố thu thập dữ liệu (trên đường ống pipeline) khỏi các sự kiện thực tế (trên hệ thống đang được đo lường). Bạn không bao giờ âm thầm tự lấp đầy các khoảng trống hoặc xóa bỏ các điểm ngoại lai (outliers); bạn đề xuất phương án xử lý và nêu rõ phương án đó làm thay đổi điều gì.

## Các tệp trong kỹ năng này

- `scripts/ts_check.py` - đọc tệp CSV, suy luận khoảng thời gian lấy mẫu (sampling interval) và báo cáo các khoảng trống, dữ liệu trùng lặp, các dòng sai thứ tự, giá trị bị thiếu, đường đi ngang (flatlines), điểm đột biến (spikes) và hiện tượng đặt lại bộ đếm dưới dạng markdown hoặc JSON (chỉ sử dụng thư viện chuẩn Python 3)
- `references/anomaly-methods.md` - cách thức hoạt động của các phép kiểm tra, cách tinh chỉnh ngưỡng (thresholds) và những trường hợp mỗi phương pháp không phát huy tác dụng
- `references/gap-handling-playbook.md` - cách xử lý cho từng loại vấn đề: điền giá trị (fill), nội suy (interpolate), loại bỏ (drop) hoặc ghi chú (annotate)
- `templates/data-quality-report.md` - mẫu báo cáo phục vụ việc sửa lỗi dashboard, đánh giá sự cố hoặc bàn giao bộ dữ liệu
- `examples/api-metrics.csv` - dữ liệu số liệu API mỗi 5 phút trong suốt ba giờ, bao gồm một đợt gián đoạn thu thập dữ liệu, một dòng trùng lặp, một giá trị bị treo, một điểm đột biến độ trễ và một lần đặt lại bộ đếm

## Quy trình thực hiện

### 1. Tìm hiểu chuỗi dữ liệu
Hỏi xem mỗi cột đo lường đại lượng gì, đơn vị tính, khoảng thời gian lấy mẫu kỳ vọng là bao nhiêu, và liệu có cột nào là bộ đếm đơn điệu (monotonic counter) hay không (ví dụ: requests_total, bytes_sent). Hỏi xem các dấu thời gian không kèm múi giờ (naive timestamps) đang sử dụng múi giờ nào; kịch bản mặc định coi chúng là giờ UTC.

### 2. Chạy công cụ kiểm tra
```
python3 scripts/ts_check.py examples/api-metrics.csv --counter requests_total
python3 scripts/ts_check.py data.csv --time-col ts --cols temp_c,humidity --interval 10m
python3 scripts/ts_check.py data.csv --z 5 --window 12 --flatline-points 12 --json > report.json
python3 scripts/ts_check.py --self-test
```
Dấu thời gian có thể ở định dạng ISO 8601 hoặc thời gian Unix epoch tính bằng giây hoặc mili-giây. Trả về mã thoát (exit code) là 1 khi phát hiện bất kỳ vấn đề nào, và 2 khi đầu vào không hợp lệ, nhờ đó có thể dùng làm cổng kiểm soát (gate) trong đường ống dữ liệu.

### 3. Đọc kết quả
- **Độ đầy đủ (Completeness)** = số dòng hiện có / số dòng kỳ vọng trên toàn dải thời gian. Mức dưới 95% thường đồng nghĩa với việc các con số tổng hợp và trung bình đã bị sai lệch.
- **gap**: khoảng cách giữa các điểm vượt quá 1.5 lần khoảng lấy mẫu. Các khoảng trống dài xuất hiện vào những khung giờ cố định thường là do quá trình triển khai (deploy) hoặc tác vụ định kỳ (cron job); một khoảng trống dài đơn lẻ thường do bộ thu thập dữ liệu (collector) bị ngừng hoạt động.
- **duplicate / out_of_order**: thu thập dữ liệu bị đúp, cơ chế thử lại (retries) hoặc do gộp nhiều tệp với nhau.
- **flatline**: cùng một giá trị lặp đi lặp lại; đây là điều bình thường đối với tín hiệu bật/tắt (on/off), nhưng lại rất đáng ngờ đối với các phép đo vốn có độ nhiễu tự nhiên.
- **spike**: điểm z-score bền vững (robust z-score) so với trung vị trượt (rolling median). Hãy kiểm tra xem các cột lân cận có tăng đột biến cùng lúc hay không (nếu có thì là sự kiện thực tế, nếu không thì là lỗi nhiễu dữ liệu).
- **reset**: bộ đếm bị sụt giảm giá trị, thường do tiến trình khởi động lại; các phép tính tốc độ thay đổi (rate) bắt buộc phải xử lý trường hợp này.

### 4. Đề xuất phương án xử lý
Sử dụng `references/gap-handling-playbook.md`. Tinh chỉnh các ngưỡng bằng `references/anomaly-methods.md` nếu dữ liệu có tính mùa vụ, biến động theo từng đợt (bursty) hoặc rất thưa thớt.

### 5. Báo cáo
Điền vào mẫu `templates/data-quality-report.md` với các con số tóm tắt, những vấn đề hàng đầu kèm theo thời gian theo múi giờ của người dùng, nguyên nhân nghi ngờ và phương án xử lý đã thống nhất.

## Quy tắc

- Chỉ báo cáo các vấn đề; không tự ý sửa đổi dữ liệu nguồn. Ghi dữ liệu đã làm sạch ra một tệp mới và ghi lại tài liệu cho mọi thay đổi đã thực hiện.
- Không kết luận một điểm đột biến (spike) là sự cố hệ thống nếu chưa có bằng chứng củng cố (từ các số liệu khác, nhật ký hệ thống hoặc xác nhận của con người).
- Nêu rõ khoảng lấy mẫu và các ngưỡng bạn đã sử dụng để kết quả có thể tái lập lại được.
- Chuyển đổi giờ UTC sang múi giờ của người dùng khi trình bày kết quả.
FILE:references/anomaly-methods.md
# Phương pháp Phát hiện Bất thường và Tinh chỉnh Tham số

## Suy luận khoảng thời gian lấy mẫu
Khoảng thời gian lấy mẫu kỳ vọng là trung vị của khoảng cách giữa các dấu thời gian duy nhất đã được sắp xếp tăng dần. Cách tính này vẫn chính xác ngay cả khi có một vài khoảng trống và độ trễ dao động nhỏ (jitter). Hãy truyền tham số `--interval` khi chuỗi dữ liệu phần lớn là khoảng trống (dữ liệu sự kiện thưa thớt) hoặc khi bạn đã biết chắc chắn chu kỳ thu thập dữ liệu.

Khoảng trống (gap) là bất kỳ khoảng cách nào lớn hơn 1.5 lần khoảng lấy mẫu; vì vậy với dữ liệu 5 phút một lần, khoảng cách 7 phút không bị coi là khoảng trống nhưng khoảng cách 10 phút thì được tính là một khoảng trống (thiếu 1 điểm dữ liệu).

## Phát hiện điểm đột biến bền vững (trung vị trượt + MAD)
Đối với mỗi điểm, lấy tối đa `--window` điểm lân cận ở mỗi bên (không tính chính điểm đang xét), tính trung vị (median) và độ lệch tuyệt đối trung vị (MAD - median absolute deviation) của chúng. Điểm z-score bền vững được tính theo công thức:

    z = |value - median| / (1.4826 * MAD)

Hệ số 1.4826 giúp quy đổi MAD tương đương với độ lệch chuẩn đối với dữ liệu phân phối chuẩn. Ngưỡng mặc định bằng 4 tương ứng với xác suất khoảng 1 trên 15.000 đối với nhiễu phân phối chuẩn. Khi có hơn một nửa số điểm lân cận mang giá trị giống hệt nhau (MAD = 0), độ lệch tuyệt đối trung bình (mean absolute deviation) sẽ được sử dụng thay thế, và nếu toàn bộ vùng lân cận hoàn toàn phẳng thì lấy bằng 1% mức giá trị đó.

Tại sao không dùng giá trị trung bình (mean) và độ lệch chuẩn (standard deviation)? Chỉ cần một điểm đột biến lớn cũng đủ làm phình đại độ lệch chuẩn và khiến chính điểm đột biến đó bị che khuất. Ngược lại, trung vị và MAD hoàn toàn miễn nhiễm với hiện tượng này.

## Tinh chỉnh tham số
| Loại dữ liệu | Gợi ý cấu hình |
|---|---|
| Số liệu nhiều nhiễu (độ trễ, CPU) | `--z 5`, `--window 12` |
| Có tính mùa vụ theo ngày hoặc tuần | Kiểm tra từng chu kỳ mùa vụ một, hoặc so sánh với cùng khung giờ tuần trước trước khi tin vào các điểm đột biến |
| Dữ liệu kinh doanh tăng vọt theo đợt (đơn hàng) | Tăng `--z` cao hơn (6-8); các điểm vọt lên thường là sự kiện thật |
| Giá trị số nguyên hoặc độ phân giải thấp | Thường gặp MAD = 0; hãy tăng `--z` hoặc tổng hợp (aggregate) dữ liệu trước |
| Tín hiệu bật/tắt hoặc trạng thái | Bỏ qua cảnh báo flatline; chỉ kiểm tra các khoảng trống (gaps) |
| Bộ đếm (Counters) | Luôn truyền cờ `--counter`; kiểm tra tốc độ biến thiên (rates) sau khi đã xử lý các điểm reset |

## Các giới hạn đã biết
- Sự trượt giá trị từ từ (gradual drifts) và sự dịch chuyển nền giá trị (level shifts) không phải là điểm đột biến ngắn hạn; hãy vẽ biểu đồ chuỗi dữ liệu hoặc so sánh trung vị giữa các tuần.
- Một vài điểm đầu tiên và cuối cùng có ít điểm lân cận hơn, do đó điểm số của chúng kém tin cậy hơn.
- Các luồng sự kiện không đều đặn (lượt đăng nhập, lượt mua hàng) không có khoảng lấy mẫu cố định; hãy gom nhóm (aggregate) chúng vào các khung thời gian (buckets) trước.
FILE:references/gap-handling-playbook.md
# Cẩm nang Xử lý Khoảng trống và Điểm bất thường

| Vấn đề | Nguyên nhân khả dĩ | Phương án xử lý mặc định | Điều cần tránh |
|---|---|---|---|
| Khoảng trống ngắn (1-2 điểm) | quá thời gian thu thập (scrape timeout), chập chờn mạng thoáng qua | Nội suy tuyến tính cho các tín hiệu mượt; giữ nguyên giá trị null đối với số đếm | Điền số 0 vào các cột số đếm |
| Khoảng trống dài | trình thu thập ngừng chạy, đang triển khai hệ thống, máy chủ tắt | Giữ nguyên null và ghi chú lên biểu đồ; loại trừ khỏi phép tính SLO hoặc tính là trạng thái chưa xác định (unknown) | Nội suy bắc cầu qua nhiều giờ liền |
| Dấu thời gian trùng lặp | thu thập đúp, chạy lại lô dữ liệu (retried batch), gộp nhiều tệp | Giữ lại một dòng duy nhất (dòng đầu tiên hoặc dòng có nhiều trường dữ liệu nhất); kiểm tra xem các giá trị có khác nhau không | Cộng gộp các dòng trùng lặp |
| Dòng dữ liệu sai thứ tự thời gian | dữ liệu đến muộn, nối ghép các tệp | Sắp xếp lại theo thời gian trước khi thực hiện bất kỳ phép tính trượt (windowed calculation) nào | Mặc định thứ tự dòng trong tệp là thứ tự thời gian |
| Giá trị bị thiếu (Missing values) | cảm biến mất tín hiệu tạm thời, lỗi phân tích cú pháp, chuỗi "N/A" | Sửa lỗi bộ phân tích cú pháp (parser) trước; sau đó xử lý như một khoảng trống | Coi ô trống là giá trị 0 |
| Đường đi ngang (Flatline) | trình xuất dữ liệu bị treo, giá trị lấy từ bộ nhớ đệm, cảm biến bị đơ | Xác nhận chéo với nguồn dữ liệu thứ hai; đánh dấu là đáng ngờ | Tin tưởng dữ liệu này để kích hoạt cảnh báo |
| Điểm đột biến (Spike đơn lẻ) | lỗi nhiễu thoáng qua, sai đơn vị đo, đợt tăng tải thực tế | Kiểm tra các cột liên quan và nhật ký hệ thống; chỉ giới hạn trần (cap) hoặc loại bỏ khi có lý do được ghi chép rõ ràng | Xóa bỏ mà không để lại ghi chú |
| Đặt lại bộ đếm (Counter reset) | tiến trình khởi động lại, tràn bộ đếm (overflow) | Tính tốc độ (rate) theo từng phân đoạn (tốc độ sau khi reset = giá trị mới / khoảng thời gian) | Để xuất hiện tốc độ âm |

## Trước khi xây dựng mô hình hoặc dự báo
1. Sắp xếp, khử trùng lặp và chuẩn hóa về đúng khoảng thời gian lấy mẫu kỳ vọng (chèn giá trị null vào các vị trí bị khuyết).
2. Quyết định chính sách xử lý khoảng trống cho từng cột và ghi nhận lại.
3. Duy trì thêm một cột đánh dấu mặt nạ (`is_imputed`) để mô hình và người đọc biết những vị trí nào đã được điền dữ liệu nhân tạo.
4. Chạy lại công cụ kiểm tra trên tệp đã làm sạch và đính kèm cả hai bản báo cáo.

## Khi nào cần báo cáo nâng cấp độ ưu tiên (Escalate)
- Độ đầy đủ (Completeness) dưới 90% đối với một chỉ số được dùng để tính SLO hoặc lập hóa đơn thanh toán.
- Các khoảng trống lặp đi lặp lại vào cùng một thời điểm mỗi ngày (có một tác vụ ngầm nào đó đang làm sập bộ thu thập dữ liệu).
- Các điểm đột biến xuất hiện đồng loạt trên tất cả các máy chủ cùng một lúc (đây là sự cố hệ thống thực sự, không phải lỗi dữ liệu).
FILE:templates/data-quality-report.md
# Chất lượng Dữ liệu Chuỗi thời gian - <tên bộ dữ liệu hoặc dashboard>

**Nguồn:** <tệp / truy vấn>  **Phạm vi:** <bắt đầu> đến <kết thúc> (<múi giờ>)  **Khoảng lấy mẫu:** <kỳ vọng>
**Số dòng:** <n>  **Độ đầy đủ:** <phần trăm>  **Ngưỡng áp dụng:** z=<z>, window=<w>, flatline=<n>

## Tóm tắt
<hai câu: dữ liệu này có đáng tin cậy cho mục đích sử dụng dự kiến hay không, và điều gì cần phải thay đổi trước tiên>

## Các vấn đề phát hiện
| Loại vấn đề | Thời điểm (<múi giờ>) | Cột | Chi tiết | Nguyên nhân nghi ngờ | Phương án xử lý |
|---|---|---|---|---|---|
| | | | | | |

## Các quyết định xử lý
- Chính sách xử lý khoảng trống: <nội suy các khoảng trống ngắn / giữ nguyên null / loại trừ>
- Chính sách xử lý điểm ngoại lai: <giữ nguyên / giới hạn trần / loại bỏ kèm ghi chú>
- Xử lý bộ đếm: <tính tốc độ theo phân đoạn tại các điểm reset>

## Công việc tiếp theo
- [ ] <sửa bộ thu thập dữ liệu / bộ phân tích cú pháp / cảnh báo> - người phụ trách <tên> - hạn hoàn thành <ngày>

Được tạo bằng scripts/ts_check.py. Dữ liệu nguồn không bị sửa đổi.
FILE:examples/api-metrics.csv
timestamp,p95_latency_ms,error_rate,requests_total
2026-10-18T22:00:00Z,182,0.4,1200
2026-10-18T22:05:00Z,176,0.3,2435
2026-10-18T22:10:00Z,190,0.5,3705
2026-10-18T22:15:00Z,185,0.4,5010
2026-10-18T22:20:00Z,179,0.3,6210
2026-10-18T22:25:00Z,188,0.4,7445
2026-10-18T22:30:00Z,181,0.5,8715
2026-10-18T22:35:00Z,193,0.4,10020
2026-10-18T22:35:00Z,193,0.4,10020
2026-10-18T22:40:00Z,177,0.3,11220
2026-10-18T22:45:00Z,184,0.4,12455
2026-10-18T22:50:00Z,186,0.4,13725
2026-10-18T22:55:00Z,180,0.4,15030
2026-10-18T23:00:00Z,191,0.4,16230
2026-10-18T23:05:00Z,,0.4,17465
2026-10-18T23:10:00Z,178,0.4,18735
2026-10-18T23:15:00Z,187,0.4,20040
2026-10-18T23:20:00Z,182,0.4,21240
2026-10-18T23:25:00Z,189,0.4,22475
2026-10-18T23:30:00Z,612,2.1,23745
2026-10-18T23:35:00Z,185,0.5,25050
2026-10-18T23:40:00Z,180,0.3,26250
2026-10-18T23:45:00Z,184,0.4,27485
2026-10-19T00:10:00Z,188,0.5,28755
2026-10-19T00:15:00Z,183,0.4,30060
2026-10-19T00:20:00Z,177,0.3,31260
2026-10-19T00:25:00Z,185,0.4,32495
2026-10-19T00:30:00Z,182,0.5,1180
2026-10-19T00:35:00Z,189,0.4,2485
2026-10-19T00:40:00Z,184,0.3,3685
2026-10-19T00:45:00Z,180,0.4,4920
2026-10-19T00:50:00Z,186,0.5,6190
2026-10-19T00:55:00Z,183,0.4,7495
FILE:scripts/ts_check.py
#!/usr/bin/env python3
"""Time series gap and anomaly checker for CSV metrics (Python 3 standard library only).

Reads a CSV with a timestamp column and one or more numeric columns, infers the sampling
interval (median spacing) unless --interval is given, and reports:
  gap          missing samples (spacing > 1.5 x interval), with how many points are missing
  duplicate    repeated timestamps
  out_of_order timestamps that go backwards
  missing      empty, NaN, or non-numeric values
  flatline     the same value repeated for >= --flatline-points samples (stuck sensor/exporter)
  spike        robust outliers: |value - rolling median| / (1.4826 * MAD) >= --z (default 4);
               if MAD is 0 the mean absolute deviation is used instead
  reset        a counter column that decreases (--counter col1,col2)

Timestamps: ISO 8601 (with Z or offset; naive = UTC) or Unix epoch seconds/milliseconds.

Usage:
  python3 ts_check.py metrics.csv
  python3 ts_check.py metrics.csv --time-col ts --cols cpu,latency_ms --interval 5m --z 5
  python3 ts_check.py metrics.csv --counter requests_total --json
  python3 ts_check.py --self-test
Exit code: 0 clean, 1 issues found, 2 bad input.
"""
import argparse
import csv
import datetime as dt
import io
import json
import math
import re
import statistics
import sys


class InputError(ValueError):
    pass


def parse_ts(raw):
    s = (raw or "").strip()
    if not s:
        raise InputError("empty timestamp")
    if re.fullmatch(r"-?\d+(\.\d+)?", s):
        v = float(s)
        return v / 1000.0 if v > 1e11 else v
    try:
        t = dt.datetime.fromisoformat(s.replace("Z", "+00:00").replace(" ", "T", 1))
    except ValueError:
        raise InputError(f"unparseable timestamp {s!r}")
    if t.tzinfo is None:
        t = t.replace(tzinfo=dt.timezone.utc)
    return t.timestamp()


def parse_interval(s):
    m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?", s.strip())
    if not m:
        raise InputError(f"bad interval {s!r}; use e.g. 30s, 5m, 1h")
    mult = {"ms": 0.001, "s": 1, None: 1, "m": 60, "h": 3600, "d": 86400}[m.group(2)]
    return float(m.group(1)) * mult


def fmt_ts(ts):
    return dt.datetime.fromtimestamp(ts, dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def fmt_dur(sec):
    for unit, size in (("d", 86400), ("h", 3600), ("m", 60)):
        if sec >= size:
            v = sec / size
            return f"{v:.0f}{unit}" if v == int(v) else f"{v:.1f}{unit}"
    return f"{sec:g}s"


def to_float(raw):
    s = (raw or "").strip()
    if s == "" or s.lower() in ("nan", "null", "none", "na", "n/a", "-"):
        return None
    try:
        v = float(s)
    except ValueError:
        return None
    return None if math.isnan(v) or math.isinf(v) else v


def load(text, time_col=None, cols=None):
    reader = csv.DictReader(io.StringIO(text))
    if not reader.fieldnames:
        raise InputError("CSV has no header row")
    fields = [f.strip() for f in reader.fieldnames]
    reader.fieldnames = fields
    if time_col is None:
        cands = [f for f in fields if f.lower() in ("timestamp", "time", "ts", "date", "datetime")]
        time_col = cands[0] if cands else fields[0]
    if time_col not in fields:
        raise InputError(f"time column {time_col!r} not in header {fields}")
    value_cols = cols or [f for f in fields if f != time_col]
    for c in value_cols:
        if c not in fields:
            raise InputError(f"column {c!r} not in header {fields}")
    rows = []
    for i, r in enumerate(reader, start=2):
        rows.append({"line": i, "ts": parse_ts(r.get(time_col)),
                     "raw": {c: r.get(c) for c in value_cols}})
    if len(rows) < 3:
        raise InputError("need at least 3 data rows")
    return time_col, value_cols, rows


def rolling_robust(values, idx, window):
    lo, hi = max(0, idx - window), min(len(values), idx + window + 1)
    neigh = [values[j] for j in range(lo, hi) if j != idx and values[j] is not None]
    if len(neigh) < 4:
        return None, None
    med = statistics.median(neigh)
    mad = statistics.median(abs(v - med) for v in neigh)
    scale = 1.4826 * mad
    if scale == 0:  # more than half the neighbours equal the median: fall back to mean absolute deviation
        scale = 1.2533 * statistics.fmean(abs(v - med) for v in neigh)
    if scale == 0:  # perfectly flat neighbourhood: 1% of the level
        scale = max(abs(med) * 0.01, 1e-9)
    return med, scale


def check(text, time_col=None, cols=None, interval=None, z=4.0, window=6, flatline_points=6, counters=()):
    time_col, value_cols, rows = load(text, time_col, cols)
    issues = []

    def add(kind, line, ts, col, detail):
        issues.append({"kind": kind, "line": line, "time": fmt_ts(ts), "column": col, "detail": detail})

    # order and duplicates (in file order)
    seen = {}
    for prev, cur in zip(rows, rows[1:]):
        if cur["ts"] < prev["ts"]:
            add("out_of_order", cur["line"], cur["ts"], time_col, f"goes back {fmt_dur(prev['ts'] - cur['ts'])} from line {prev['line']}")
    for r in rows:
        if r["ts"] in seen:
            add("duplicate", r["line"], r["ts"], time_col, f"same timestamp as line {seen[r['ts']]}")
        else:
            seen[r["ts"]] = r["line"]
    srt = sorted({r["ts"]: r for r in reversed(rows)}.values(), key=lambda r: r["ts"])  # first occurrence wins
    diffs = [b["ts"] - a["ts"] for a, b in zip(srt, srt[1:]) if b["ts"] > a["ts"]]
    if interval is None:
        if not diffs:
            raise InputError("cannot infer interval: all timestamps identical")
        interval = statistics.median(diffs)
    expected = int(round((srt[-1]["ts"] - srt[0]["ts"]) / interval)) + 1
    missing_points = 0
    for a, b in zip(srt, srt[1:]):
        d = b["ts"] - a["ts"]
        if d > 1.5 * interval:
            n = int(round(d / interval)) - 1
            missing_points += n
            add("gap", b["line"], a["ts"], time_col, f"{fmt_dur(d)} without data after {fmt_ts(a['ts'])} (~{n} missing)")

    stats = {}
    for c in value_cols:
        vals = [to_float(r["raw"][c]) for r in srt]
        for r, v in zip(srt, vals):
            if v is None:
                add("missing", r["line"], r["ts"], c, f"value {r['raw'][c]!r} is empty or not numeric")
        # flatline runs
        run_start = 0
        for i in range(1, len(vals) + 1):
            if i < len(vals) and vals[i] is not None and vals[i] == vals[run_start]:
                continue
            length = i - run_start
            if vals[run_start] is not None and length >= flatline_points:
                r0, r1 = srt[run_start], srt[i - 1]
                add("flatline", r0["line"], r0["ts"], c, f"value {vals[run_start]:g} repeated {length}x until {fmt_ts(r1['ts'])}")
            run_start = i
        if c in counters:
            for i in range(1, len(vals)):
                if vals[i] is not None and vals[i - 1] is not None and vals[i] < vals[i - 1]:
                    add("reset", srt[i]["line"], srt[i]["ts"], c, f"counter dropped {vals[i - 1]:g} -> {vals[i]:g} (restart or wrap)")
        else:
            for i, v in enumerate(vals):
                if v is None:
                    continue
                med, scale = rolling_robust(vals, i, window)
                if med is None:
                    continue
                score = abs(v - med) / scale
                if score >= z:
                    add("spike", srt[i]["line"], srt[i]["ts"], c, f"{v:g} vs local median {med:g} (robust z {score:.1f})")
        good = [v for v in vals if v is not None]
        stats[c] = {"count": len(good), "min": min(good) if good else None, "max": max(good) if good else None,
                    "median": statistics.median(good) if good else None}

    order = {"out_of_order": 0, "duplicate": 1, "gap": 2, "reset": 3, "missing": 4, "flatline": 5, "spike": 6}
    issues.sort(key=lambda x: (order[x["kind"]], x["time"], x["column"]))
    counts = {}
    for it in issues:
        counts[it["kind"]] = counts.get(it["kind"], 0) + 1
    return {
        "rows": len(rows), "time_column": time_col, "columns": value_cols,
        "start": fmt_ts(srt[0]["ts"]), "end": fmt_ts(srt[-1]["ts"]),
        "interval": fmt_dur(interval), "interval_seconds": interval,
        "expected_points": expected, "missing_points": missing_points,
        "completeness": round(100.0 * len(srt) / expected, 1) if expected else 100.0,
        "stats": stats, "counts": counts, "issues": issues,
    }


def to_markdown(r):
    lines = ["# Time series check", "",
             f"Rows: {r['rows']}  Range: {r['start']} to {r['end']}  Interval: {r['interval']}",
             f"Completeness: {r['completeness']}% ({r['missing_points']} of {r['expected_points']} expected points missing)", "",
             "| Column | Count | Min | Median | Max |", "|---|---|---|---|---|"]
    for c, s in r["stats"].items():
        f = lambda v: "-" if v is None else f"{v:g}"
        lines.append(f"| {c} | {s['count']} | {f(s['min'])} | {f(s['median'])} | {f(s['max'])} |")
    lines += ["", "## Issues"]
    if not r["issues"]:
        lines.append("No issues found.")
    else:
        lines.append("Totals: " + ", ".join(f"{k} {v}" for k, v in r["counts"].items()))
        lines += ["", "| Kind | Time (UTC) | Column | Line | Detail |", "|---|---|---|---|---|"]
        lines += [f"| {i['kind']} | {i['time']} | {i['column']} | {i['line']} | {i['detail']} |" for i in r["issues"]]
    return "\n".join(lines)


def self_test():
    cases = []

    def ok(name, cond):
        cases.append((name, bool(cond)))

    base = 1760860800  # 2025-10-19T08:00:00Z
    def csv_of(points, header="timestamp,value"):
        return header + "\n" + "\n".join(f"{t},{v}" for t, v in points) + "\n"
    wave = [10 + (i % 3) for i in range(30)]
    clean = csv_of([(base + 60 * i, v) for i, v in enumerate(wave)])
    r = check(clean)
    ok("clean series has no issues", r["issues"] == [])
    ok("interval inferred as 1m", r["interval"] == "1m")
    ok("completeness 100%", r["completeness"] == 100.0)
    gap_pts = [(base + 60 * i, v) for i, v in enumerate(wave) if not 10 <= i < 15]
    r = check(csv_of(gap_pts))
    ok("gap detected", r["counts"].get("gap") == 1)
    ok("gap counts 5 missing points", r["missing_points"] == 5)
    spiky = list(wave); spiky[20] = 95
    r = check(csv_of([(base + 60 * i, v) for i, v in enumerate(spiky)]))
    ok("spike detected", [i["line"] for i in r["issues"] if i["kind"] == "spike"] == [22])
    flat = list(wave); flat[5:13] = [7] * 8
    r = check(csv_of([(base + 60 * i, v) for i, v in enumerate(flat)]), z=100)
    ok("flatline detected", r["counts"].get("flatline") == 1)
    miss = [(base + 60 * i, ("" if i == 4 else "NaN" if i == 9 else v)) for i, v in enumerate(wave)]
    ok("missing values detected", check(csv_of(miss)).get("counts").get("missing") == 2)
    dup = [(base + 60 * i, v) for i, v in enumerate(wave)]
    dup.insert(6, dup[5])
    ok("duplicate detected", check(csv_of(dup))["counts"].get("duplicate") == 1)
    ooo = [(base + 60 * i, v) for i, v in enumerate(wave)]
    ooo[7], ooo[8] = ooo[8], ooo[7]
    ok("out of order detected", check(csv_of(ooo))["counts"].get("out_of_order") == 1)
    counter = [(base + 60 * i, 100 * i if i < 20 else 100 * (i - 20)) for i in range(30)]
    r = check(csv_of(counter, "ts,requests_total"), counters=("requests_total",))
    ok("counter reset detected", r["counts"].get("reset") == 1 and "spike" not in r["counts"])
    iso_rows = "time,cpu\n" + "\n".join(f"2026-10-19T08:{i:02d}:00Z,{40 + (i % 2)}" for i in range(0, 60, 5)) + "\n"
    r = check(iso_rows)
    ok("ISO timestamps and 5m interval", r["interval"] == "5m" and r["issues"] == [])
    ms_rows = csv_of([((base + 30 * i) * 1000, v) for i, v in enumerate(wave)])
    ok("epoch milliseconds accepted", check(ms_rows)["interval"] == "30s")
    r = check(clean, interval=30.0)
    ok("explicit 30s interval flags every 1m step as a gap", r["counts"].get("gap") == 29 and r["completeness"] < 60)
    jitter = csv_of([(base + 60 * i + (3 if i % 2 else -2), v) for i, v in enumerate(wave)])
    ok("small timestamp jitter is not a gap", "gap" not in check(jitter)["counts"])
    multi = "timestamp,a,b\n" + "\n".join(f"{base + 60 * i},{wave[i]},{wave[i] * 2 if i != 12 else 500}" for i in range(30)) + "\n"
    r = check(multi, cols=["a", "b"])
    ok("multi-column spike only in b", {i["column"] for i in r["issues"]} == {"b"})
    for bad, label in (("timestamp,value\n1,2\n", "too few rows rejected"),
                       ("timestamp,value\nyesterday,1\ntoday,2\nnow,3\n", "bad timestamp rejected")):
        try:
            check(bad)
            ok(label, False)
        except InputError:
            ok(label, True)
    ok("markdown renders", "## Issues" in to_markdown(check(clean)))
    passed = sum(c for _, c in cases)
    for name, c in cases:
        print(("PASS " if c else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Find gaps, duplicates, flatlines, spikes, and counter resets in CSV metrics.")
    ap.add_argument("csv", nargs="?", help="CSV file, or '-' for stdin")
    ap.add_argument("--time-col")
    ap.add_argument("--cols", help="comma-separated value columns (default: all except the time column)")
    ap.add_argument("--interval", help="expected spacing, e.g. 30s, 5m, 1h (default: median spacing)")
    ap.add_argument("--z", type=float, default=4.0, help="robust z-score threshold for spikes (default 4)")
    ap.add_argument("--window", type=int, default=6, help="neighbours on each side for the rolling median (default 6)")
    ap.add_argument("--flatline-points", type=int, default=6)
    ap.add_argument("--counter", default="", help="comma-separated monotonic counter columns (checked for resets, not spikes)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.csv:
        ap.error("csv path required (or --self-test)")
    try:
        text = sys.stdin.read() if a.csv == "-" else open(a.csv, encoding="utf-8-sig").read()
        r = check(text, a.time_col, [c.strip() for c in a.cols.split(",")] if a.cols else None,
                  parse_interval(a.interval) if a.interval else None, a.z, a.window, a.flatline_points,
                  tuple(c.strip() for c in a.counter.split(",") if c.strip()))
    except (OSError, InputError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else to_markdown(r))
    return 1 if r["issues"] else 0


if __name__ == "__main__":
    sys.exit(main())
PythonData AnalysisData Quality+3
F@f
0
Công Cụ Tính Ngân Sách Lỗi và Tốc Độ Tiêu Hao SLO
Kỹ năng

Chuyển đổi số lượng yêu cầu thành các chỉ số trả lời cho SLO: SLI, ngân sách lỗi cho khung thời gian 28 ngày hoặc 30 ngày, ngân sách đã tiêu thụ và còn lại, tốc độ tiêu hao (burn rate) trong khoảng từ 5 phút đến 3 ngày, cảnh báo tốc độ tiêu hao đa khung thời gian (gọi trực khẩn cấp ở mức 14.4x và 6x, tạo ticket ở mức 3x và 1x, được quy đổi theo khung thời gian), và thời gian cho đến khi cạn kiệt ngân sách. Sử dụng khi thiết lập SLO, tinh chỉnh cảnh báo, quyết định đóng băng phát hành hoặc viết tóm tắt sự cố. Bao gồm một công cụ tính bằng Python dùng thư viện chuẩn với tùy chọn --self-test.

---
name: slo-error-budget-burn-rate-calculator
description: Chuyển đổi số lượng yêu cầu thành các chỉ số trả lời cho SLO: SLI, ngân sách lỗi cho khung thời gian 28 ngày hoặc 30 ngày, ngân sách đã tiêu thụ và còn lại, tốc độ tiêu hao (burn rate) trong khoảng từ 5 phút đến 3 ngày, cảnh báo tốc độ tiêu hao đa khung thời gian (gọi trực khẩn cấp ở mức 14.4x và 6x, tạo ticket ở mức 3x và 1x, được quy đổi theo khung thời gian), và thời gian cho đến khi cạn kiệt ngân sách. Sử dụng khi thiết lập SLO, tinh chỉnh cảnh báo, quyết định đóng băng phát hành hoặc viết tóm tắt sự cố. Bao gồm một công cụ tính bằng Python dùng thư viện chuẩn với tùy chọn --self-test.
---

# Công Cụ Tính Ngân Sách Lỗi và Tốc Độ Tiêu Hao SLO

Bạn giúp các kỹ sư và đội ngũ trực sự cố (on-call) đánh giá độ tin cậy của hệ thống dựa trên số liệu thay vì cảm tính. Bạn chuyển đổi số lượng yêu cầu thành công/tổng số yêu cầu thành chỉ số SLI, quy đổi mục tiêu SLO thành ngân sách lỗi (error budget), chỉ ra tốc độ tiêu hao ngân sách qua nhiều khung thời gian, cho biết cảnh báo nào cần gọi trực khẩn cấp (page) hoặc mở ticket, đồng thời ước tính thời điểm ngân sách sẽ cạn kiệt nếu giữ nguyên tốc độ hiện tại. Bạn giải thích từng con số bằng ngôn ngữ dễ hiểu để các chủ sản phẩm (product owner) có thể nắm bắt và đưa ra quyết định.

## Các tệp trong kỹ năng này

- `scripts/slo_budget.py` - đọc tệp CSV chứa số lượng yêu cầu theo từng khoảng thời gian và in ra SLI, ngân sách đã tiêu thụ và còn lại, tốc độ tiêu hao, trạng thái cảnh báo, thời gian dự kiến cạn kiệt và khoảng thời gian tệ nhất; tùy chọn `--allowance` in ra thời gian ngừng hoạt động cho phép và ngưỡng cảnh báo cho bất kỳ mức SLO nào (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/slo-math.md` - công thức tính SLI, ngân sách lỗi, tốc độ tiêu hao, mức tiêu thụ dự kiến và các con số tính sẵn cho những mục tiêu phổ biến
- `references/burn-rate-alerting.md` - chính sách cảnh báo đa khung thời gian, đa tốc độ tiêu hao, cách các ngưỡng thay đổi theo khung thời gian và lời khuyên tinh chỉnh
- `templates/slo-status-report.md` - mẫu báo cáo trạng thái SLO hàng tuần hoặc tóm tắt ngân sách sau sự cố
- `examples/checkout-api-requests.csv` - dữ liệu số lượng yêu cầu theo từng khoảng 15 phút trong hai ngày của một API thanh toán, có một đợt gián đoạn nhỏ và một sự cố kéo dài 2 giờ ở cuối kỳ

## Quy trình làm việc

### 1. Xác định SLI và SLO
Hỏi rõ thế nào được coi là một sự kiện "tốt" (mã trạng thái < 500, độ trễ dưới 300 ms, tác vụ chạy thành công), dữ liệu đếm lấy từ đâu, mục tiêu là bao nhiêu (99%, 99.5%, 99.9%, 99.95%), và khung thời gian tính là gì (28 hoặc 30 ngày trượt). Hãy phản biện lại những mục tiêu mà hệ thống chưa từng đạt được trong thực tế: luôn hiển thị chỉ số SLI lịch sử trước tiên.

### 2. Thu thập dữ liệu
Xuất số lượng sự kiện tốt và tổng số sự kiện (hoặc sự kiện lỗi và tổng số sự kiện) theo từng khoảng thời gian, ví dụ các khung 1 phút, 5 phút hoặc 15 phút, dưới dạng tệp CSV với các cột `timestamp,total,good`. Cột timestamp đánh dấu thời điểm bắt đầu của mỗi khoảng thời gian. Sử dụng múi giờ UTC.

### 3. Chạy công cụ tính
```
python3 scripts/slo_budget.py examples/checkout-api-requests.csv --slo 99.9 --window 30d
python3 scripts/slo_budget.py requests.csv --slo 99.5 --window 28d --json > slo.json
python3 scripts/slo_budget.py requests.csv --now 2026-10-19T22:00:00Z
python3 scripts/slo_budget.py --allowance --slo 99.95 --window 30d
python3 scripts/slo_budget.py --self-test
```
Mã thoát (exit code) bằng 1 có nghĩa là cảnh báo gọi trực khẩn cấp (page alert) đang kích hoạt hoặc ngân sách đã cạn kiệt; bằng 2 có nghĩa là dữ liệu đầu vào không hợp lệ (thiếu cột, số lỗi > tổng số, không phân tích được định dạng timestamp).

### 4. Diễn giải kết quả
- Tốc độ tiêu hao (burn rate) bằng 1.0 nghĩa là tiêu thụ vừa hết ngân sách khi kết thúc khung thời gian; mức 14.4 duy trì trong một giờ sẽ tiêu tốn 2% ngân sách của cả 30 ngày.
- Ngân sách đã tiêu thụ được phóng chiếu dựa trên lưu lượng truy cập ghi nhận được cho đến hiện tại; nếu dữ liệu chưa đủ một khung thời gian đầy đủ, hãy nêu rõ điều đó.
- Một cảnh báo chỉ kích hoạt khi cả khung thời gian dài và khung thời gian ngắn của nó đều vượt ngưỡng; khung thời gian ngắn giúp cảnh báo nhanh chóng tắt đi sau khi hệ thống phục hồi. Xem thêm trong `references/burn-rate-alerting.md`.
- Các khung thời gian ngắn hơn bước nhảy dữ liệu sẽ được làm tròn lên thành một bước nhảy; các khung thời gian dài hơn tổng thời lượng dữ liệu sẽ có trạng thái là "not evaluable" (không thể đánh giá).

### 5. Đưa ra khuyến nghị và báo cáo
Gắn kết các con số với một quyết định cụ thể: gọi trực khẩn cấp ngay, mở ticket, làm chậm các đợt phát hành tiềm ẩn rủi ro khi ngân sách còn lại thấp, hoặc nới lỏng một SLO vốn luôn bị vi phạm do đặc thù thiết kế. Điền thông tin vào `templates/slo-status-report.md`.

## Kết quả ví dụ
Đối với `examples/checkout-api-requests.csv` ở mức SLO 99.9% trong 30 ngày: SLI đạt 99.8768%, đã tiêu thụ 8.2% ngân sách, tốc độ tiêu hao trong 1h là 26.46 và trong 5m là 28.06 nên cảnh báo gọi trực nhanh được kích hoạt, tốc độ tiêu hao trong 6h là 5.83 nằm ngay dưới ngưỡng 6.0, cảnh báo ticket 3d ở trạng thái không thể đánh giá (not evaluable) vì chỉ có hai ngày dữ liệu, và với tốc độ của giờ cuối cùng thì ngân sách sẽ cạn sạch sau khoảng 1 ngày 16 giờ. Mã thoát là 1.

## Quy tắc

- Luôn nêu rõ SLO, khung thời gian, phạm vi dữ liệu và bất kỳ khoảng thời gian bị thiếu nào kèm theo mỗi kết quả.
- Không trộn lẫn nhiều định nghĩa sự kiện tốt vào chung một SLI; hãy tính các SLO riêng biệt cho độ sẵn sàng (availability) và độ trễ (latency).
- SLO là công cụ hỗ trợ ra quyết định, không phải công cụ quy trách nhiệm: hãy mô tả tác động lên người dùng, không nhằm vào cá nhân.
- Không tự bịa ra số liệu lưu lượng truy cập; nếu thiếu dữ liệu đếm, hãy yêu cầu cung cấp hoặc nêu rõ kết quả nào không thể tính toán được.
FILE:references/slo-math.md
# Toán Học Trong SLO

## Các định nghĩa
- **SLI** (service level indicator - chỉ số mức dịch vụ) = số sự kiện tốt / tổng số sự kiện, tính theo phần trăm.
- **SLO** (objective - mục tiêu mức dịch vụ) = mức SLI mục tiêu trong một khung thời gian, ví dụ: 99.9% yêu cầu thành công trong 30 ngày trượt.
- **Ngân sách lỗi (Error budget)** = 1 - SLO = tỷ lệ sự kiện được phép thất bại. Mức 99.9% -> 0.1%.
- **Ngân sách tính theo số sự kiện** = (1 - SLO) x tổng số sự kiện trong khung thời gian.
- **Tỷ lệ lỗi (Error rate)** trong một khung thời gian = số sự kiện lỗi / tổng số sự kiện trong khung thời gian đó.
- **Tốc độ tiêu hao (Burn rate)** = tỷ lệ lỗi / (1 - SLO). Mức 1.0 có nghĩa là ngân sách sẽ được sử dụng vừa hết vào thời điểm kết thúc khung thời gian.

## Mức tiêu thụ dự kiến với dữ liệu một phần
Với D giây dữ liệu thực tế và W giây của khung thời gian SLO:
- số yêu cầu dự kiến trong toàn khung = (số yêu cầu trong dữ liệu / D) x W
- số lỗi cho phép = (1 - SLO) x số yêu cầu dự kiến
- tỷ lệ đã tiêu thụ = số lỗi trong dữ liệu / số lỗi cho phép
- thời gian cho đến khi cạn kiệt theo tốc độ gần đây = (số lỗi cho phép - số lỗi tính đến hiện tại) / (số lỗi mỗi giây trong khung thời gian gần đây đó)

## Thời gian ngừng hoạt động hoàn toàn tối đa cho phép
| SLO | mỗi 30 ngày | mỗi 28 ngày | mỗi tuần | mỗi ngày |
|---|---|---|---|---|
| 99% | 7h 12m | 6h 43m 12s | 1h 40m 48s | 14m 24s |
| 99.5% | 3h 36m | 3h 21m 36s | 50m 24s | 7m 12s |
| 99.9% | 43m 12s | 40m 19s | 10m 5s | 1m 26s |
| 99.95% | 21m 36s | 20m 10s | 5m 2s | 43s |
| 99.99% | 4m 19s | 4m 2s | 1m 0s | 9s |

Các SLO dựa trên số lượng yêu cầu không phụ thuộc trực tiếp vào thời gian đồng hồ: 10 phút bị lỗi 50% sẽ tiêu tốn lượng ngân sách tương đương với 5 phút ngừng hoạt động hoàn toàn nếu có cùng lưu lượng truy cập, và một sự cố ngừng hoạt động lúc 3 giờ sáng sẽ tốn ít ngân sách hơn so với sự cố xảy ra vào giờ cao điểm.

## Ví dụ minh họa cách tính
SLO 99.9%, khung thời gian 30 ngày, 1,000,000 yêu cầu mỗi ngày.
- Ngân sách: 0.001 x 30,000,000 = 30,000 yêu cầu thất bại.
- Tỷ lệ lỗi 2% kéo dài trong một giờ ở mức lưu lượng 41,667 yêu cầu/giờ sẽ làm tiêu hao 833 lỗi = 2.8% ngân sách, tương ứng tốc độ tiêu hao là 20.
- Ở tốc độ tiêu hao bằng 20, toàn bộ ngân sách sẽ chỉ trụ được trong 30 ngày / 20 = 36 giờ.

## Các bẫy thường gặp
- Đếm các lần thử lại (retry) như những sự kiện tốt riêng biệt sẽ che giấu các lỗi mà người dùng thực sự gặp phải.
- Lưu lượng truy cập quá thấp (ban đêm, khu vực mới mở) khiến tỷ lệ lỗi bị nhiễu mạnh; hãy áp dụng số lượng yêu cầu tối thiểu hoặc dùng khung thời gian ngắn dài hơn.
- Các phép đo giả lập (synthetic probes) đo lường chính công cụ thăm dò chứ không phải người dùng thực tế; hãy gắn nhãn tách biệt cho chúng.
- Thay đổi SLO giữa chừng khung thời gian: hãy tính toán lại toàn bộ khung thời gian theo mục tiêu mới và ghi chú rõ điều này.
FILE:references/burn-rate-alerting.md
# Cảnh Báo Theo Tốc Độ Tiêu Hao (Burn-Rate Alerting)

## Chính sách mặc định (đa khung thời gian, đa tốc độ tiêu hao)
| Mức độ nghiêm trọng | Tỷ phần ngân sách | Khung dài | Khung ngắn | Ngưỡng tiêu hao (30d) | Ngưỡng tiêu hao (28d) |
|---|---|---|---|---|---|
| page | 2% | 1h | 5m | 14.4 | 13.44 |
| page | 5% | 6h | 30m | 6.0 | 5.6 |
| ticket | 10% | 1d | 2h | 3.0 | 2.8 |
| ticket | 10% | 3d | 6h | 1.0 | 0.93 |

Ngưỡng = tỷ phần ngân sách x khung thời gian SLO / khung dài. Ví dụ: 0.02 x 720h / 1h = 14.4.

## Tại sao cần kết hợp hai khung thời gian
- **Khung thời gian dài (long window)** chứng minh sự cố đủ nghiêm trọng (đã làm tiêu tốn một lượng ngân sách đáng kể).
- **Khung thời gian ngắn (short window)** (bằng khoảng 1/12 khung dài) chứng minh sự cố vẫn đang tiếp diễn, nhờ đó cảnh báo sẽ tự động tắt chỉ sau vài phút khi hệ thống phục hồi thay vì tiếp tục báo đỏ suốt cả tiếng đồng hồ.
- Chỉ phát cảnh báo khi cả hai khung thời gian cùng vượt ngưỡng.

## Ví dụ quy tắc cảnh báo (theo cú pháp Prometheus)
```
(
  sum(rate(http_requests_errors_total[1h])) / sum(rate(http_requests_total[1h])) > (14.4 * 0.001)
and
  sum(rate(http_requests_errors_total[5m])) / sum(rate(http_requests_total[5m])) > (14.4 * 0.001)
)
```

## Tinh chỉnh cảnh báo
- Các dịch vụ có lưu lượng thấp: yêu cầu số lượng yêu cầu tối thiểu trong khung ngắn, hoặc chỉ gọi trực khẩn cấp dựa trên khung 6h.
- Bị gọi trực quá nhiều lần: hãy kiểm tra xem SLO có đang đặt khắt khe hơn mức người dùng thực sự cần hay không trước khi nâng ngưỡng cảnh báo.
- Bỏ sót sự cố: các đợt tiêu hao âm ỉ dưới mức 1.0 theo thiết kế sẽ không bao giờ phát cảnh báo; hãy rà soát ngân sách hàng tuần.
- Bước nhảy dữ liệu thô hơn khung thời gian ngắn (ví dụ: dữ liệu gom theo từng khung 15 phút): khi đó khung ngắn sẽ bị quy về thành một khung dữ liệu duy nhất và việc phát hiện hệ thống phục hồi sẽ chậm hơn.

## Gợi ý chính sách quản lý ngân sách lỗi
- Đã tiêu thụ trên 50%: rà soát kỹ các thay đổi tiềm ẩn rủi ro, ưu tiên triển khai kiểu canary.
- Đã tiêu thụ trên 75%: đóng băng phát hành đối với các thay đổi không phải bản vá lỗi, ưu tiên công việc củng cố độ tin cậy lên hàng đầu.
- Cạn kiệt ngân sách: bắt buộc thực hiện họp rút kinh nghiệm sau sự cố (postmortem); đóng băng phát hành cho đến khi khung thời gian trượt phục hồi lại ngân sách.
FILE:templates/slo-status-report.md
# Trạng Thái SLO - <service> - <period or incident>

**SLI:** <what counts as good>  **SLO:** <target>% trên khung trượt <28d/30d>
**Dữ liệu:** <start> đến <end> UTC, bước nhảy <interval>, thiếu <n> khoảng thời gian
**Công cụ tính:** `slo_budget.py --slo <target> --window <window>` (mã thoát <code>)

## Ngân sách
| Chỉ số | Giá trị |
|---|---|
| SLI trong kỳ | |
| Ngân sách đã tiêu thụ | |
| Ngân sách còn lại | |
| Thời gian ngừng hoạt động hoàn toàn cho phép mỗi khung | |

## Tốc độ tiêu hao (Burn rates)
| 5m | 30m | 1h | 2h | 6h | 1d | 3d |
|---|---|---|---|---|---|---|
| | | | | | | |

## Cảnh báo
| Chính sách | Trạng thái | Mức tiêu hao dài / ngắn |
|---|---|---|
| page 1h/5m | | |
| page 6h/30m | | |
| ticket 1d/2h | | |
| ticket 3d/6h | | |

## Chuyện gì đã xảy ra
<khoảng thời gian tệ nhất, tác động tới người dùng, nguyên nhân nếu đã rõ>

## Quyết định
- [ ] Gọi trực khẩn cấp / mở sự cố
- [ ] Chính sách phát hành: <bình thường / thận trọng / đóng băng> vì <ngân sách còn lại>
- [ ] Tinh chỉnh SLO hoặc cảnh báo: <nội dung thay đổi và lý do>

## Tóm tắt dành cho các bên liên quan
> <service> đạt mức <SLI>% so với mục tiêu <target>%. Đã sử dụng <x>% ngân sách lỗi của khung thời gian này; với tốc độ hiện tại, ngân sách sẽ duy trì được trong <time>. Bước tiếp theo: <decision>.
FILE:examples/checkout-api-requests.csv
timestamp,total,good
2026-10-18T00:00:00Z,4757,4755
2026-10-18T00:15:00Z,4525,4523
2026-10-18T00:30:00Z,4313,4311
2026-10-18T00:45:00Z,4122,4120
2026-10-18T01:00:00Z,3951,3949
2026-10-18T01:15:00Z,3803,3801
2026-10-18T01:30:00Z,3678,3676
2026-10-18T01:45:00Z,3577,3576
2026-10-18T02:00:00Z,3500,3498
2026-10-18T02:15:00Z,3448,3446
2026-10-18T02:30:00Z,3421,3419
2026-10-18T02:45:00Z,3019,3018
2026-10-18T03:00:00Z,3044,3043
2026-10-18T03:15:00Z,3093,3092
2026-10-18T03:30:00Z,3169,3168
2026-10-18T03:45:00Z,3270,3268
2026-10-18T04:00:00Z,3396,3394
2026-10-18T04:15:00Z,3547,3545
2026-10-18T04:30:00Z,3722,3720
2026-10-18T04:45:00Z,3921,3919
2026-10-18T05:00:00Z,4143,4141
2026-10-18T05:15:00Z,4388,4386
2026-10-18T05:30:00Z,4253,4251
2026-10-18T05:45:00Z,4539,4537
2026-10-18T06:00:00Z,4845,4843
2026-10-18T06:15:00Z,5168,5166
2026-10-18T06:30:00Z,5509,5507
2026-10-18T06:45:00Z,5865,5863
2026-10-18T07:00:00Z,6236,6234
2026-10-18T07:15:00Z,6619,6616
2026-10-18T07:30:00Z,7013,7009
2026-10-18T07:45:00Z,7418,7414
2026-10-18T08:00:00Z,7831,7827
2026-10-18T08:15:00Z,7850,7847
2026-10-18T08:30:00Z,8274,8271
2026-10-18T08:45:00Z,8702,8699
2026-10-18T09:00:00Z,9132,9127
2026-10-18T09:15:00Z,9561,9556
2026-10-18T09:30:00Z,9989,9984
2026-10-18T09:45:00Z,10413,10408
2026-10-18T10:00:00Z,10832,10827
2026-10-18T10:15:00Z,11245,11240
2026-10-18T10:30:00Z,11650,11645
2026-10-18T10:45:00Z,12044,12038
2026-10-18T11:00:00Z,12028,12022
2026-10-18T11:15:00Z,12398,12392
2026-10-18T11:30:00Z,12754,12748
2026-10-18T11:45:00Z,13095,13089
2026-10-18T12:00:00Z,13418,13412
2026-10-18T12:15:00Z,13724,13719
2026-10-18T12:30:00Z,14010,14003
2026-10-18T12:45:00Z,14275,14268
2026-10-18T13:00:00Z,14520,14513
2026-10-18T13:15:00Z,14742,14735
2026-10-18T13:30:00Z,14941,14934
2026-10-18T13:45:00Z,14716,14710
2026-10-18T14:00:00Z,14867,14861
2026-10-18T14:15:00Z,14993,14985
2026-10-18T14:30:00Z,15094,15086
2026-10-18T14:45:00Z,15170,15163
2026-10-18T15:00:00Z,15220,15213
2026-10-18T15:15:00Z,15244,15237
2026-10-18T15:30:00Z,15242,15236
2026-10-18T15:45:00Z,15215,15209
2026-10-18T16:00:00Z,15163,15155
2026-10-18T16:15:00Z,14686,14679
2026-10-18T16:30:00Z,14585,14578
2026-10-18T16:45:00Z,14460,14453
2026-10-18T17:00:00Z,14312,14306
2026-10-18T17:15:00Z,14141,14135
2026-10-18T17:30:00Z,13950,13866
2026-10-18T17:45:00Z,13738,13656
2026-10-18T18:00:00Z,13506,13499
2026-10-18T18:15:00Z,13257,13251
2026-10-18T18:30:00Z,12990,12984
2026-10-18T18:45:00Z,12708,12702
2026-10-18T19:00:00Z,12012,12007
2026-10-18T19:15:00Z,11702,11697
2026-10-18T19:30:00Z,11382,11376
2026-10-18T19:45:00Z,11051,11045
2026-10-18T20:00:00Z,10712,10707
2026-10-18T20:15:00Z,10367,10362
2026-10-18T20:30:00Z,10017,10013
2026-10-18T20:45:00Z,9663,9659
2026-10-18T21:00:00Z,9308,9304
2026-10-18T21:15:00Z,8952,8947
2026-10-18T21:30:00Z,8598,8594
2026-10-18T21:45:00Z,7848,7844
2026-10-18T22:00:00Z,7503,7500
2026-10-18T22:15:00Z,7164,7161
2026-10-18T22:30:00Z,6833,6830
2026-10-18T22:45:00Z,6513,6510
2026-10-18T23:00:00Z,6204,6201
2026-10-18T23:15:00Z,5907,5904
2026-10-18T23:30:00Z,5625,5622
2026-10-18T23:45:00Z,5358,5356
2026-10-19T00:00:00Z,5109,5107
2026-10-19T00:15:00Z,4877,4875
2026-10-19T00:30:00Z,4265,4263
2026-10-19T00:45:00Z,4074,4072
2026-10-19T01:00:00Z,3903,3901
2026-10-19T01:15:00Z,3755,3753
2026-10-19T01:30:00Z,3630,3628
2026-10-19T01:45:00Z,3529,3527
2026-10-19T02:00:00Z,3452,3451
2026-10-19T02:15:00Z,3400,3399
2026-10-19T02:30:00Z,3373,3371
2026-10-19T02:45:00Z,3371,3369
2026-10-19T03:00:00Z,3396,3394
2026-10-19T03:15:00Z,3045,3044
2026-10-19T03:30:00Z,3121,3120
2026-10-19T03:45:00Z,3222,3221
2026-10-19T04:00:00Z,3348,3347
2026-10-19T04:15:00Z,3499,3497
2026-10-19T04:30:00Z,3674,3672
2026-10-19T04:45:00Z,3873,3871
2026-10-19T05:00:00Z,4095,4093
2026-10-19T05:15:00Z,4340,4338
2026-10-19T05:30:00Z,4605,4603
2026-10-19T05:45:00Z,4491,4489
2026-10-19T06:00:00Z,4797,4795
2026-10-19T06:15:00Z,5120,5117
2026-10-19T06:30:00Z,5461,5458
2026-10-19T06:45:00Z,5817,5814
2026-10-19T07:00:00Z,6188,6185
2026-10-19T07:15:00Z,6571,6568
2026-10-19T07:30:00Z,6965,6962
2026-10-19T07:45:00Z,7370,7366
2026-10-19T08:00:00Z,7783,7779
2026-10-19T08:15:00Z,8202,8198
2026-10-19T08:30:00Z,8226,8222
2026-10-19T08:45:00Z,8654,8650
2026-10-19T09:00:00Z,9084,9080
2026-10-19T09:15:00Z,9513,9509
2026-10-19T09:30:00Z,9941,9936
2026-10-19T09:45:00Z,10365,10360
2026-10-19T10:00:00Z,10784,10779
2026-10-19T10:15:00Z,11197,11192
2026-10-19T10:30:00Z,11602,11597
2026-10-19T10:45:00Z,11996,11991
2026-10-19T11:00:00Z,12380,12375
2026-10-19T11:15:00Z,12350,12344
2026-10-19T11:30:00Z,12706,12700
2026-10-19T11:45:00Z,13047,13041
2026-10-19T12:00:00Z,13370,13364
2026-10-19T12:15:00Z,13676,13670
2026-10-19T12:30:00Z,13962,13956
2026-10-19T12:45:00Z,14227,14221
2026-10-19T13:00:00Z,14472,14464
2026-10-19T13:15:00Z,14694,14687
2026-10-19T13:30:00Z,14893,14886
2026-10-19T13:45:00Z,15068,15061
2026-10-19T14:00:00Z,14819,14812
2026-10-19T14:15:00Z,14945,14939
2026-10-19T14:30:00Z,15046,15040
2026-10-19T14:45:00Z,15122,15114
2026-10-19T15:00:00Z,15172,15164
2026-10-19T15:15:00Z,15196,15189
2026-10-19T15:30:00Z,15194,15187
2026-10-19T15:45:00Z,15167,15160
2026-10-19T16:00:00Z,15115,15109
2026-10-19T16:15:00Z,15038,15032
2026-10-19T16:30:00Z,14937,14929
2026-10-19T16:45:00Z,14412,14405
2026-10-19T17:00:00Z,14264,14257
2026-10-19T17:15:00Z,14093,14087
2026-10-19T17:30:00Z,13902,13896
2026-10-19T17:45:00Z,13690,13684
2026-10-19T18:00:00Z,13458,13453
2026-10-19T18:15:00Z,13209,13202
2026-10-19T18:30:00Z,12942,12936
2026-10-19T18:45:00Z,12660,12654
2026-10-19T19:00:00Z,12364,12358
2026-10-19T19:15:00Z,11654,11649
2026-10-19T19:30:00Z,11334,11329
2026-10-19T19:45:00Z,11003,10999
2026-10-19T20:00:00Z,10664,10658
2026-10-19T20:15:00Z,10319,10314
2026-10-19T20:30:00Z,9969,9964
2026-10-19T20:45:00Z,9615,9611
2026-10-19T21:00:00Z,9260,9256
2026-10-19T21:15:00Z,8904,8900
2026-10-19T21:30:00Z,8550,8547
2026-10-19T21:45:00Z,8200,8196
2026-10-19T22:00:00Z,7455,7298
2026-10-19T22:15:00Z,7116,6959
2026-10-19T22:30:00Z,6785,6629
2026-10-19T22:45:00Z,6465,6310
2026-10-19T23:00:00Z,6156,6002
2026-10-19T23:15:00Z,5859,5707
2026-10-19T23:30:00Z,5577,5426
2026-10-19T23:45:00Z,5310,5161
FILE:scripts/slo_budget.py
#!/usr/bin/env python3
"""SLO error budget and burn rate calculator (Python 3 standard library only).

Input: CSV with one row per interval, the timestamp marking the START of the interval, and request
counts. Accepted columns (case-insensitive): timestamp|time|ts, total|requests|count, and either
good|success|ok or bad|errors|failed. Timestamps: ISO 8601 (Z or offset) or Unix seconds.

Math (see references/slo-math.md):
  error budget (fraction)  = 1 - SLO                          e.g. 99.9% -> 0.001
  error rate over window   = sum(bad) / sum(total)
  burn rate                = error rate / (1 - SLO)           1.0 = spends exactly the budget over the SLO window
  budget consumed so far   = sum(bad) / ((1 - SLO) * expected requests in the SLO window)
  alert threshold          = budget share * window hours / long window hours   (14.4 = 2% of 30d in 1h)

Default multiwindow, multi-burn-rate policy (Google SRE Workbook): page 2% in 1h (short 5m),
page 5% in 6h (short 30m), ticket 10% in 1d (short 2h), ticket 10% in 3d (short 6h). An alert fires
only when BOTH the long and the short window burn faster than the threshold. Windows shorter than
the data step are rounded up to one step; windows longer than the data are reported as not evaluable.

Usage:
  python3 slo_budget.py requests.csv --slo 99.9 --window 30d
  python3 slo_budget.py requests.csv --slo 99.5 --window 28d --json
  python3 slo_budget.py --allowance --slo 99.95 --window 30d     # downtime allowed, no CSV needed
  python3 slo_budget.py --self-test
Exit code: 0 OK, 1 a page alert fires or the budget is exhausted, 2 bad input.
"""
import argparse
import csv
import datetime as dt
import json
import re
import statistics
import sys

POLICY = [
    # (severity, long window, short window, budget share)
    ("page", "1h", "5m", 0.02),
    ("page", "6h", "30m", 0.05),
    ("ticket", "1d", "2h", 0.10),
    ("ticket", "3d", "6h", 0.10),
]
ALIASES = {
    "ts": ("timestamp", "time", "ts", "date", "datetime"),
    "total": ("total", "requests", "count", "events"),
    "good": ("good", "success", "ok", "successes"),
    "bad": ("bad", "errors", "failed", "failures", "error"),
}


class SLOError(ValueError):
    pass


def parse_duration(s):
    m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([smhdw])\s*", str(s).lower())
    if not m:
        raise SLOError(f"bad duration {s!r} (use e.g. 5m, 1h, 28d)")
    return float(m.group(1)) * {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}[m.group(2)]


def fmt_dur(sec):
    sec = int(round(sec))
    if sec >= 86400:
        d, h = divmod(sec // 3600, 24)
        return f"{d}d {h}h" if h else f"{d}d"
    if sec >= 3600:
        h, m = divmod(sec // 60, 60)
        return f"{h}h {m}m" if m else f"{h}h"
    if sec >= 60:
        m, s = divmod(sec, 60)
        return f"{m}m {s}s" if s else f"{m}m"
    return f"{sec}s"


def parse_ts(v):
    v = v.strip()
    if re.fullmatch(r"\d+(\.\d+)?", v):
        return dt.datetime.fromtimestamp(float(v), dt.timezone.utc)
    t = dt.datetime.fromisoformat(v.replace("Z", "+00:00").replace(" ", "T"))
    if t.tzinfo is None:
        t = t.replace(tzinfo=dt.timezone.utc)
    return t.astimezone(dt.timezone.utc)


def load_rows(lines):
    rd = csv.DictReader(lines)
    if not rd.fieldnames:
        raise SLOError("empty CSV")
    cols = {}
    lower = {f.strip().lower(): f for f in rd.fieldnames}
    for key, names in ALIASES.items():
        for n in names:
            if n in lower:
                cols[key] = lower[n]
                break
    if "ts" not in cols or "total" not in cols or not ({"good", "bad"} & cols.keys()):
        raise SLOError(f"need timestamp, total and good or bad columns; got {rd.fieldnames}")
    rows = []
    for i, r in enumerate(rd, 2):
        try:
            ts = parse_ts(r[cols["ts"]])
            total = int(float(r[cols["total"]]))
            bad = int(float(r[cols["bad"]])) if "bad" in cols else total - int(float(r[cols["good"]]))
        except (ValueError, TypeError) as e:
            raise SLOError(f"line {i}: {e}")
        if total < 0 or bad < 0 or bad > total:
            raise SLOError(f"line {i}: counts must satisfy 0 <= bad <= total (total={total}, bad={bad})")
        rows.append((ts, total, bad))
    if len(rows) < 2:
        raise SLOError("need at least two rows")
    rows.sort(key=lambda r: r[0])
    return rows


def analyze(rows, slo_pct, window, now=None, policy=POLICY):
    if not 0 < slo_pct < 100:
        raise SLOError("SLO must be between 0 and 100 (exclusive), e.g. 99.9")
    budget = 1 - slo_pct / 100
    win = parse_duration(window)
    diffs = [(b[0] - a[0]).total_seconds() for a, b in zip(rows, rows[1:])]
    step = statistics.median(diffs)
    if step <= 0:
        raise SLOError("duplicate timestamps")
    first = rows[0][0]
    end = (now or rows[-1][0] + dt.timedelta(seconds=step))
    covered = (end - first).total_seconds()
    expected_rows = int(round(covered / step))
    missing = max(0, expected_rows - len(rows))

    def window_stats(seconds):
        seconds = max(seconds, step)
        if seconds > covered + 1e-6:
            return None
        start = end - dt.timedelta(seconds=seconds)
        tot = sum(t for ts, t, b in rows if start <= ts < end)
        bad = sum(b for ts, t, b in rows if start <= ts < end)
        rate = bad / tot if tot else 0.0
        return {"seconds": seconds, "total": tot, "bad": bad, "error_rate": rate, "burn_rate": rate / budget}

    total = sum(r[1] for r in rows)
    bad = sum(r[2] for r in rows)
    req_per_sec = total / covered
    expected_window_total = req_per_sec * win
    allowed_bad = budget * expected_window_total
    consumed = bad / allowed_bad if allowed_bad else 0.0
    alerts = []
    for sev, lw, sw, share in policy:
        thr = share * win / parse_duration(lw)
        L, S = window_stats(parse_duration(lw)), window_stats(parse_duration(sw))
        if L is None or S is None:
            alerts.append({"severity": sev, "long": lw, "short": sw, "threshold": thr, "status": "not evaluable",
                           "long_burn": None, "short_burn": None})
            continue
        fires = L["burn_rate"] >= thr and S["burn_rate"] >= thr
        alerts.append({"severity": sev, "long": lw, "short": sw, "threshold": thr,
                       "status": "FIRING" if fires else "ok",
                       "long_burn": L["burn_rate"], "short_burn": S["burn_rate"]})
    burn = {}
    for w in ("5m", "30m", "1h", "2h", "6h", "1d", "3d"):
        s = window_stats(parse_duration(w))
        burn[w] = None if s is None else s["burn_rate"]
    remaining_bad = allowed_bad - bad
    exhaust = {}
    for w in ("1h", "6h", "1d"):
        s = window_stats(parse_duration(w))
        if s and s["bad"] > 0 and remaining_bad > 0:
            exhaust[w] = remaining_bad / (s["bad"] / s["seconds"])
        elif s:
            exhaust[w] = 0.0 if remaining_bad <= 0 else None
    worst = max(rows, key=lambda r: (r[2] / r[1] if r[1] else 0, r[0]))
    return {
        "slo_percent": slo_pct, "window": window, "window_seconds": win, "step_seconds": step,
        "data_start": first.strftime("%Y-%m-%dT%H:%M:%SZ"), "data_end": end.strftime("%Y-%m-%dT%H:%M:%SZ"),
        "rows": len(rows), "missing_intervals": missing,
        "total": total, "bad": bad, "sli_percent": 100 * (1 - bad / total) if total else 100.0,
        "allowed_bad_in_window": allowed_bad, "budget_consumed": consumed, "budget_remaining": 1 - consumed,
        "allowed_downtime_seconds": budget * win,
        "burn_rates": burn, "alerts": alerts, "exhaustion_seconds": exhaust,
        "worst_interval": {"start": worst[0].strftime("%Y-%m-%dT%H:%M:%SZ"), "total": worst[1], "bad": worst[2],
                           "error_rate": worst[2] / worst[1] if worst[1] else 0.0},
    }


def allowance_table(slo_pct, window):
    win = parse_duration(window)
    b = 1 - slo_pct / 100
    return {"slo_percent": slo_pct, "window": window, "allowed_downtime_seconds": b * win,
            "per_day_seconds": b * 86400, "per_week_seconds": b * 604800,
            "thresholds": [{"severity": s, "long": l, "short": sh, "share": sha, "burn_rate": sha * win / parse_duration(l)}
                           for s, l, sh, sha in POLICY]}


def to_markdown(r):
    pct = lambda x: f"{100 * x:.1f}%"
    out = [f"# SLO error budget report", "",
           f"**SLO:** {r['slo_percent']}% over {r['window']}  **Data:** {r['data_start']} to {r['data_end']} "
           f"({r['rows']} rows, step {fmt_dur(r['step_seconds'])}, {r['missing_intervals']} missing)",
           f"**SLI over data:** {r['sli_percent']:.4f}%  ({r['bad']:,} bad of {r['total']:,})",
           f"**Error budget:** {r['allowed_bad_in_window']:,.0f} bad requests per {r['window']} at current traffic "
           f"(= {fmt_dur(r['allowed_downtime_seconds'])} of full downtime)",
           f"**Consumed so far:** {pct(r['budget_consumed'])}  **Remaining:** {pct(r['budget_remaining'])}", "",
           "## Burn rates (1.0 = on budget)", "", "| Window | Burn rate |", "|---|---|"]
    for w, v in r["burn_rates"].items():
        out.append(f"| {w} | {'n/a' if v is None else f'{v:.2f}'} |")
    out += ["", "## Alert policy", "", "| Severity | Long / short window | Threshold | Long burn | Short burn | Status |",
            "|---|---|---|---|---|---|"]
    for a in r["alerts"]:
        lb = "n/a" if a["long_burn"] is None else f"{a['long_burn']:.2f}"
        sb = "n/a" if a["short_burn"] is None else f"{a['short_burn']:.2f}"
        out.append(f"| {a['severity']} | {a['long']} / {a['short']} | {a['threshold']:.1f} | {lb} | {sb} | {a['status']} |")
    out += ["", "## Time to exhaustion at recent burn"]
    for w, s in r["exhaustion_seconds"].items():
        out.append(f"- at last {w} rate: " + ("budget already exhausted" if s == 0 else ("no errors in window" if s is None else fmt_dur(s))))
    wi = r["worst_interval"]
    out += ["", f"Worst interval: {wi['start']} with {wi['bad']:,} bad of {wi['total']:,} ({100 * wi['error_rate']:.2f}%)"]
    return "\n".join(out)


def self_test():
    cases = []

    def ok(name, cond):
        cases.append((name, bool(cond)))

    t0 = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc)

    def series(n, step_min, total, bad_fn):
        return [(t0 + dt.timedelta(minutes=step_min * i), total, bad_fn(i)) for i in range(n)]

    ok("duration parse", parse_duration("28d") == 28 * 86400 and parse_duration("5m") == 300)
    ok("duration format", fmt_dur(43.2 * 60) == "43m 12s" and fmt_dur(86400 * 3) == "3d")
    a = allowance_table(99.9, "30d")
    ok("99.9% of 30d allows 43m 12s", round(a["allowed_downtime_seconds"]) == 2592)
    ok("page threshold 14.4 for 30d", abs(a["thresholds"][0]["burn_rate"] - 14.4) < 1e-9)
    ok("28d threshold scales to 13.44", abs(allowance_table(99.9, "28d")["thresholds"][0]["burn_rate"] - 13.44) < 1e-9)
    flat = series(4 * 24 * 4, 15, 10000, lambda i: 10)          # 0.1% errors at 99.9 -> burn 1.0
    r = analyze(flat, 99.9, "30d")
    ok("steady burn rate is 1.0", abs(r["burn_rates"]["1h"] - 1.0) < 1e-9 and abs(r["burn_rates"]["1d"] - 1.0) < 1e-9)
    ok("no alert at burn 1.0", all(x["status"] != "FIRING" for x in r["alerts"][:3]))
    ok("burn 1.0 over 4 of 30 days consumes 4/30", abs(r["budget_consumed"] - 4 / 30) < 1e-9)
    ok("SLI computed", abs(r["sli_percent"] - 99.9) < 1e-9)
    spike = series(4 * 24 * 4, 15, 10000, lambda i: 200 if i >= 4 * 24 * 4 - 4 else 5)   # last hour 2% errors
    r = analyze(spike, 99.9, "30d")
    ok("fast burn pages (1h/5m)", r["alerts"][0]["status"] == "FIRING")
    ok("short window rounded up to step", abs(r["burn_rates"]["5m"] - 20.0) < 1e-9)
    ok("6h page not firing for 1h spike", r["alerts"][1]["status"] == "ok")
    recovered = spike[:-2] + [(spike[-2][0], 10000, 5), (spike[-1][0], 10000, 5)]
    r = analyze(recovered, 99.9, "30d")
    ok("short window resets page after recovery", r["alerts"][0]["status"] == "ok")
    short = series(8, 15, 1000, lambda i: 0)
    r = analyze(short, 99.9, "30d")
    ok("long windows not evaluable on 2h of data", r["alerts"][3]["status"] == "not evaluable" and r["burn_rates"]["3d"] is None)
    gap = flat[:10] + flat[12:]
    ok("missing intervals counted", analyze(gap, 99.9, "30d")["missing_intervals"] == 2)
    ok("exhaustion time at burn 1", abs(analyze(flat, 99.9, "30d")["exhaustion_seconds"]["1d"] - 26 * 86400) < 1)
    rows = load_rows(["time,requests,errors", "2026-01-01T00:00:00Z,100,1", "2026-01-01T00:05:00Z,100,0"])
    ok("aliases requests/errors", rows[0][1] == 100 and rows[0][2] == 1)
    rows = load_rows(["ts,total,good", "1767225600,50,49", "1767225900,50,50"])
    ok("unix timestamps and good column", rows[0][2] == 1 and rows[0][0] == t0)
    for data, label in [(["ts,total", "x,1"], "missing good/bad rejected"),
                        (["ts,total,bad", "2026-01-01T00:00:00Z,10,11", "2026-01-01T00:05:00Z,10,0"], "bad > total rejected"),
                        (["ts,total,bad", "nope,10,1", "2026-01-01T00:05:00Z,10,0"], "bad timestamp rejected")]:
        try:
            load_rows(data)
            ok(label, False)
        except SLOError:
            ok(label, True)
    try:
        analyze(flat, 100, "30d")
        ok("SLO 100 rejected", False)
    except SLOError:
        ok("SLO 100 rejected", True)
    ok("markdown has alert table", "## Alert policy" in to_markdown(analyze(flat, 99.9, "30d")))
    passed = sum(c for _, c in cases)
    for name, c in cases:
        print(("PASS " if c else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Compute SLO error budget consumption and multiwindow burn-rate alerts.")
    ap.add_argument("file", nargs="?", help="CSV of request counts per interval ('-' for stdin)")
    ap.add_argument("--slo", type=float, default=99.9, help="target in percent (default 99.9)")
    ap.add_argument("--window", default="30d", help="SLO window, e.g. 28d or 30d (default 30d)")
    ap.add_argument("--now", help="evaluate as of this ISO time (default: end of the last interval)")
    ap.add_argument("--allowance", action="store_true", help="print allowed downtime and alert thresholds only")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    try:
        if a.allowance:
            t = allowance_table(a.slo, a.window)
            if a.json:
                print(json.dumps(t, indent=2))
            else:
                print(f"SLO {a.slo}% over {a.window}: {fmt_dur(t['allowed_downtime_seconds'])} of full downtime "
                      f"({fmt_dur(t['per_day_seconds'])}/day, {fmt_dur(t['per_week_seconds'])}/week)")
                for x in t["thresholds"]:
                    print(f"  {x['severity']:6} {x['share'] * 100:.0f}% of budget in {x['long']} (short {x['short']}): burn rate >= {x['burn_rate']:.2f}")
            return 0
        if not a.file:
            ap.error("CSV file required (or --allowance / --self-test)")
        lines = sys.stdin.read().splitlines() if a.file == "-" else open(a.file, encoding="utf-8").read().splitlines()
        r = analyze(load_rows(lines), a.slo, a.window, parse_ts(a.now) if a.now else None)
    except (OSError, SLOError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else to_markdown(r))
    paging = any(x["severity"] == "page" and x["status"] == "FIRING" for x in r["alerts"])
    return 1 if paging or r["budget_remaining"] <= 0 else 0


if __name__ == "__main__":
    sys.exit(main())
PythonDevOpsSRE+2
F@f
0
Kubernetes taste
Văn bản

Các quy ước mang tính định hướng rõ ràng để triển khai, bảo mật và vận hành các tải công việc (workloads) trên các cụm Kubernetes.

# Gu vận hành Kubernetes của một đội ngũ kỹ thuật nền tảng (platform team)
- Triển khai mọi thứ thông qua GitOps (Argo CD hoặc Flux); không ai chạy lệnh kubectl apply trực tiếp lên môi trường production bằng tay. Độ tin cậy: 0.90
- Đóng gói các dịch vụ dùng chung bằng Helm hoặc Kustomize, nhưng giữ cho các lớp cấu hình ghi đè (overlays) theo từng môi trường luôn nhỏ gọn và dễ đọc. Độ tin cậy: 0.80
- Thiết lập mức yêu cầu (requests) về CPU và bộ nhớ trên mọi container, và đặt giới hạn (limit) bộ nhớ bằng đúng mức request. Độ tin cậy: 0.90
- Tránh đặt giới hạn trần CPU (CPU limits) trên các dịch vụ nhạy cảm với độ trễ; hãy dựa vào thông số requests và việc tính toán kích thước node để ngăn chặn tình trạng bóp băng thông xử lý (throttling). Độ tin cậy: 0.70
- Trang bị readiness probe cho mọi Deployment; chỉ thêm liveness probe khi ứng dụng thực sự có nguy cơ rơi vào trạng thái treo cứng (deadlock). Độ tin cậy: 0.85
- Sử dụng startup probe cho các ứng dụng khởi động chậm thay vì đặt giá trị initialDelaySeconds quá dài trên liveness probe. Độ tin cậy: 0.80
- Chạy ít nhất hai bản sao (replicas) đứng sau một PodDisruptionBudget đối với bất kỳ dịch vụ nào mà người dùng phụ thuộc vào. Độ tin cậy: 0.90
- Phân bổ đều các bản sao qua nhiều vùng khả dụng (zones) bằng cách sử dụng topologySpreadConstraints. Độ tin cậy: 0.80
- Ghim cố định các image bằng mã băm (digest) hoặc một thẻ phiên bản bất biến (immutable version tag), tuyệt đối không bao giờ dùng thẻ latest. Độ tin cậy: 0.95
- Chạy các container dưới quyền người dùng không phải root (non-root) với hệ thống tệp gốc chỉ đọc (read-only root filesystem) và loại bỏ toàn bộ các quyền capabilities. Độ tin cậy: 0.90
- Thực thi tiêu chuẩn bảo mật Pod mức hạn chế (restricted Pod Security Standard) trên các namespace chứa ứng dụng. Độ tin cậy: 0.80
- Khởi đầu mọi namespace bằng một NetworkPolicy mặc định chặn toàn bộ (default-deny) và chỉ mở đúng những luồng kết nối mà bạn thực sự cần. Độ tin cậy: 0.80
- Cấp cho mỗi workload một ServiceAccount riêng biệt và tắt tính năng tự động gắn kết token (automount) khi workload đó không bao giờ gọi tới API server. Độ tin cậy: 0.85
- Giữ các thông tin bí mật (secrets) nằm ngoài Git; đồng bộ chúng từ trình quản lý bí mật bằng External Secrets hoặc sử dụng Sealed Secrets. Độ tin cậy: 0.90
- Sử dụng một namespace cho mỗi nhóm (team) hoặc mỗi ứng dụng, đi kèm với ResourceQuota và LimitRange. Độ tin cậy: 0.80
- Áp dụng bộ nhãn tiêu chuẩn app.kubernetes.io cho mọi tài nguyên. Độ tin cậy: 0.75
- Xử lý tín hiệu SIGTERM và thêm một khoảng chờ ngắn (sleep) trong preStop để các yêu cầu đang xử lý dở (in-flight requests) hoàn tất trước khi tắt hẳn. Độ tin cậy: 0.85
- Mở rộng quy mô (scale) các dịch vụ không trạng thái (stateless) bằng HorizontalPodAutoscaler dựa trên mức sử dụng CPU hoặc chỉ số tốc độ yêu cầu thực tế. Độ tin cậy: 0.75
- Chạy cơ sở dữ liệu trên dịch vụ đám mây được quản lý (managed cloud service) thay vì dùng StatefulSets trừ khi có lý do thực sự thuyết phục. Độ tin cậy: 0.80
- Xác thực các tệp manifest trong quy trình CI bằng kubeconform và một công cụ kiểm tra chính sách như Kyverno hoặc OPA Gatekeeper. Độ tin cậy: 0.80
- Duy trì phiên bản của các cụm (clusters) không tụt hậu quá hai phiên bản phụ (minor versions) so với bản phát hành Kubernetes mới nhất và nâng cấp theo lịch trình định kỳ. Độ tin cậy: 0.75
- Phơi bày (expose) các dịch vụ HTTP thông qua một bộ điều khiển ingress dùng chung hoặc Gateway API controller, thay vì tạo một LoadBalancer riêng cho từng dịch vụ. Độ tin cậy: 0.75
DevOpsCloudInfrastructure+2
F@f
0
Công cụ tính toán chính sách Retry và Backoff
Kỹ năng

Kiểm tra các thiết lập retry, backoff và timeout trên toàn bộ chuỗi lệnh gọi để ngăn chặn bão retry (retry storm). Tính toán lịch trình độ trễ hàm mũ với jitter dạng full, equal hoặc decorrelated, độ trễ trường hợp xấu nhất cho mỗi tầng, số lần thử kỳ vọng và mức độ khuếch đại yêu cầu. Cảnh báo các timeout bên trong kéo dài vượt quá thời gian của bên gọi, việc retry các lệnh gọi không bất biến (non-idempotent), retry trên mã lỗi 4xx, bỏ qua Retry-After cũng như thiếu giới hạn trần (cap) hoặc hạn chót (deadline). Bao gồm một công cụ tính toán dùng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: retry-backoff-policy-calculator
description: Kiểm tra các thiết lập retry, backoff và timeout trên toàn bộ chuỗi lệnh gọi để ngăn chặn bão retry (retry storm). Tính toán lịch trình độ trễ hàm mũ với jitter dạng full, equal hoặc decorrelated, độ trễ trường hợp xấu nhất cho mỗi tầng, số lần thử kỳ vọng và mức độ khuếch đại yêu cầu. Cảnh báo các timeout bên trong kéo dài vượt quá thời gian của bên gọi, việc retry các lệnh gọi không bất biến (non-idempotent), retry trên mã lỗi 4xx, bỏ qua Retry-After cũng như thiếu giới hạn trần (cap) hoặc hạn chót (deadline). Bao gồm một công cụ tính toán dùng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Công cụ tính toán chính sách Retry và Backoff

Bạn giúp các kỹ sư thiết lập chính sách retry, backoff và timeout giúp các dịch vụ hoạt động đáng tin cậy hơn thay vì biến một sự cố nhỏ thành một sự cố sập hệ thống diện rộng. Bạn thu thập các thiết lập của từng chặng (hop) trong chuỗi lệnh gọi (trình duyệt, gateway, các dịch vụ, cơ sở dữ liệu, API bên thứ ba), tính toán những gì thực sự diễn ra khi gặp lỗi, và đề xuất một chính sách an toàn hơn kèm theo các con số cụ thể.

## Các tệp trong kỹ năng này

- `scripts/retry_policy.py` - đọc JSON chuỗi lệnh gọi và in ra lịch trình độ trễ, độ trễ trường hợp xấu nhất, số lần thử kỳ vọng, mức độ khuếch đại và các cờ cảnh báo (chỉ sử dụng thư viện chuẩn Python 3)
- `references/backoff-math.md` - các công thức tính backoff, jitter, thời gian trường hợp xấu nhất, số lần thử kỳ vọng, mức độ khuếch đại và việc lồng ghép timeout
- `references/retry-safety-guide.md` - những gì an toàn để retry, khóa bất biến (idempotency key), ngắt mạch (circuit breaker), ngân sách retry (retry budget), các thiết lập thư viện phổ biến
- `templates/retry-policy-review.md` - báo cáo đánh giá gồm chuỗi hiện tại, các phát hiện và chính sách đề xuất
- `examples/checkout-chain.json` - một chuỗi thanh toán ba tầng với những sai lầm điển hình

## Quy trình làm việc

### 1. Lập bản đồ chuỗi lệnh gọi
Hỏi thông tin cho từng chặng, bắt đầu từ tầng ngoài cùng trước: số lần thử (attempts - tổng số lần thực hiện), độ trễ cơ sở (base delay), hệ số nhân (multiplier), độ trễ tối đa (max delay), loại jitter, timeout cho mỗi lần thử (per-try timeout), hạn chót tổng thể (overall deadline), điều kiện kích hoạt retry (mã trạng thái, "timeout", "network"), liệu lệnh gọi có tính bất biến (idempotent) hay không, có gửi kèm khóa bất biến (idempotency key) hay không, và có tuân thủ Retry-After hay không. Đọc các thông số này từ mã nguồn hoặc tệp cấu hình (SDK client, bộ chuyển đổi retry của HTTP client, các route Envoy/Istio, cấu hình dịch vụ gRPC, hàng đợi công việc). Với các giá trị chưa biết: sử dụng giá trị mặc định của thư viện và đánh dấu chúng là giả định.

### 2. Chạy công cụ tính toán
```
python3 scripts/retry_policy.py examples/checkout-chain.json
python3 scripts/retry_policy.py chain.json --failure-rate 0.5 --max-amplification 8
python3 scripts/retry_policy.py chain.json --json > retry-report.json
python3 scripts/retry_policy.py --self-test
```
Mã thoát (exit code) 1 có nghĩa là có các phát hiện mức độ nghiêm trọng cao, 2 có nghĩa là dữ liệu đầu vào không hợp lệ.

### 3. Giải thích các phát hiện
Thứ tự ưu tiên: RETRY_UNSAFE và AMPLIFICATION trước tiên (chúng gây ra các tác dụng phụ trùng lặp và làm sập hệ thống), tiếp theo là INNER_OUTLIVES_CALLER, HOT_LOOP, NO_PER_TRY_TIMEOUT, sau đó đến mức trung bình (medium) và thấp (low). Giải thích từng vấn đề bằng các con số trích từ báo cáo, ví dụ: "một lần thanh toán có thể biến thành 36 lệnh gọi tới nhà cung cấp dịch vụ thanh toán".

### 4. Đề xuất chính sách đã khắc phục
Sử dụng `references/backoff-math.md` và `references/retry-safety-guide.md`: chỉ retry tại một tầng duy nhất, dùng full jitter có giới hạn trần (cap), thời gian trường hợp xấu nhất của tầng bên trong phải nhỏ hơn per-try timeout của bên gọi, truyền hạn chót (deadline) xuống các tầng dưới, dùng khóa bất biến cho các thao tác ghi, không retry trên các lỗi 4xx ngoại trừ 408/429, tuân thủ Retry-After, áp dụng ngân sách retry hoặc bộ ngắt mạch. Chạy lại kịch bản trên tệp JSON đề xuất và hiển thị kết quả so sánh trước và sau khi sửa.

### 5. Viết báo cáo tổng hợp
Điền vào biểu mẫu `templates/retry-policy-review.md` và cung cấp các đoạn mã cấu hình (config snippets) cho thư viện mà người dùng đang sử dụng.

## Ví dụ kết quả
Đối với `examples/checkout-chain.json` ở tỷ lệ lỗi 30%: mức độ khuếch đại trường hợp xấu nhất là 36 lệnh gọi cho mỗi lần thanh toán (kỳ vọng là 2.74); thời gian trường hợp xấu nhất 9.3 giây của tầng gateway kéo dài vượt quá per-try timeout 8 giây của ứng dụng web; tầng thanh toán retry một lệnh trừ tiền không bất biến 4 lần mà không có khóa bất biến và retry cả lỗi 409; không có jitter tại tầng gateway; bỏ qua Retry-After trên lỗi 429/503.

## Quy tắc

- Không bao giờ khuyến nghị retry một lệnh gọi không bất biến mà không có khóa bất biến (idempotency key).
- Giữ tính trung thực của các con số: gắn nhãn rõ ràng các giá trị mặc định của thư viện và các phỏng đoán là giả định.
- Ưu tiên số lần retry ít hơn kèm theo thiết lập timeout hợp lý hơn là retry quá nhiều lần.
- Khuyến nghị kiểm thử tải (load test) hoặc tiêm lỗi (fault injection) để xác nhận chính sách trong thực tế, chứ không chỉ dựa vào tính toán lý thuyết.
FILE:references/backoff-math.md
# Toán học về Backoff và Timeout

## Thuật ngữ
- **attempts**: tổng số lần thử bao gồm cả lần đầu tiên. attempts 3 = 1 lần thử ban đầu + 2 lần retry.
- **base**: độ trễ của lần retry đầu tiên. **multiplier**: hệ số tăng trưởng (thường là 2). **cap**: độ trễ tối đa cho một lần chờ.
- **per-try timeout**: khoảng thời gian tối đa mà một lần thử được phép diễn ra trước khi bị hủy bỏ.
- **deadline**: tổng ngân sách thời gian cho toàn bộ lệnh gọi bao gồm tất cả các lần thử và thời gian chờ.

## Độ trễ trước lần retry thứ k (k = 1 .. attempts-1)
d(k) = min(cap, base x multiplier^(k-1))

| Jitter | Độ trễ | Phạm vi | Giá trị trung bình |
|---|---|---|---|
| none | d | d | d |
| full | random(0, d) | 0 .. d | d/2 |
| equal | d/2 + random(0, d/2) | d/2 .. d | 0.75 d |
| decorrelated | min(cap, random(base, 3 x previous)) | base .. min(cap, base x 3^k) | mô phỏng |

Full jitter phân tán tải tốt nhất và là khuyến nghị mặc định. Equal jitter giữ lại một khoảng thời gian chờ tối thiểu. Decorrelated jitter thích ứng với độ trễ liền trước đó và hoạt động tốt cho các client chạy dài hạn.

## Thời lượng trường hợp xấu nhất của một lệnh gọi
worst = attempts x per_try_timeout + tổng các độ trễ tối đa, sau đó cắt tại ngưỡng deadline nếu có thiết lập deadline.
Ví dụ: 3 lần thử x 1 s + 100 ms + 200 ms = 3.3 s.

## Số lần thử kỳ vọng
Với xác suất lỗi độc lập p cho mỗi lần thử và tổng số n lần thử (attempts):
E[tries] = 1 + p + p^2 + ... + p^(n-1)
p = 0.3, n = 3 -> 1.39. Trong trường hợp sập toàn bộ hệ thống, p = 1 và E = n.

## Sự khuếch đại qua các tầng
Nếu tầng A retry 3 lần và gọi B, B lại retry 3 lần và gọi C, C lại retry 4 lần, thì một yêu cầu từ người dùng có thể biến thành 3 x 3 x 4 = 36 lệnh gọi tới C khi xảy ra sự cố. Đây chính là bão retry (retry storm): các lệnh retry giữ cho dịch vụ đang gặp sự cố luôn trong tình trạng quá tải, khiến nó không thể phục hồi.

Quy tắc kinh nghiệm:
- Chỉ retry tại một tầng duy nhất, thường là tầng gần người dùng nhất hoặc tầng sở hữu khóa bất biến (idempotency key).
- Các tầng bên trong: đặt attempts là 1 hoặc 2 với hạn chót (deadline) ngắn.
- Giữ mức độ khuếch đại trong trường hợp xấu nhất ở mức bằng hoặc dưới khoảng 10.
- Thêm **ngân sách retry (retry budget)**: số lượng retry chỉ được chiếm tối đa 10-20% tổng số yêu cầu trong một cửa sổ trượt (sliding window); vượt quá ngưỡng đó, hãy báo lỗi ngay lập tức (fail fast).

## Lồng ghép Timeout (Timeout nesting)
Trường hợp xấu nhất của mỗi tầng phải nằm gọn bên trong per-try timeout của bên gọi nó, trừ đi một khoảng dự phòng (margin) cho độ trễ mạng và xử lý:
inner_worst <= outer_per_try_timeout - margin
Nếu không, bên gọi sẽ hủy bỏ lần thử đó và thực hiện retry trong khi tầng bên trong vẫn đang tiếp tục xử lý, làm tăng gấp đôi tải hệ thống. Hãy truyền một hạn chót (deadline) xuống dọc theo chuỗi lệnh gọi (ví dụ qua header `grpc-timeout` hoặc `X-Request-Deadline`) và dừng xử lý ngay khi thời gian này cạn kiệt.

## Retry-After
Đối với mã lỗi 429 và 503, máy chủ có thể gửi kèm `Retry-After: <seconds>` hoặc một ngày giờ theo chuẩn HTTP. Hãy chờ ít nhất bằng khoảng thời gian đó (sử dụng max(Retry-After, backoff tính toán được)), và từ bỏ ngay nếu khoảng thời gian chờ này dài hơn thời hạn (deadline) còn lại.
FILE:references/retry-safety-guide.md
# Hướng dẫn an toàn khi Retry

## Những trường hợp nên retry
| Kết quả | Có Retry không? | Ghi chú |
|---|---|---|
| Từ chối kết nối (Connection refused) / ngắt kết nối (reset) trước khi yêu cầu được gửi đi | Có | An toàn cho bất kỳ phương thức nào |
| Hết thời gian chờ (Timeout) sau khi yêu cầu đã được gửi đi | Chỉ khi có tính bất biến hoặc có khóa bất biến (idempotency key) | Máy chủ có thể đã thực hiện xong công việc đó |
| 408 Request Timeout | Có, kèm theo backoff | |
| 429 Too Many Requests | Có, sau khoảng thời gian Retry-After | Không bao giờ gọi lại nhanh hơn thời gian máy chủ yêu cầu |
| 500 Internal Server Error | Đôi khi | Chỉ khi biết chắc lỗi đó là lỗi tạm thời (transient) |
| 502 / 503 / 504 | Có, kèm theo backoff và jitter | Tuân thủ Retry-After trên lỗi 503 |
| 400 / 401 / 403 / 404 / 405 / 410 / 422 | Không | Cùng một yêu cầu sẽ thất bại theo cùng một cách; hãy sửa lại yêu cầu, hoặc làm mới xác thực (auth) tối đa một lần |
| 409 Conflict | Thường là không | Ngoại lệ: một khóa bất biến vẫn đang trong quá trình xử lý, hãy retry sau một khoảng chờ ngắn |

## Tính bất biến (Idempotency)
- GET, HEAD, OPTIONS, PUT và DELETE có tính bất biến theo ngữ nghĩa HTTP, nếu máy chủ triển khai chúng đúng chuẩn.
- POST và PATCH không có tính bất biến. Để retry chúng, hãy gửi kèm một **khóa bất biến (idempotency key)** (một mã UUID cho mỗi thao tác logic, ví dụ qua header `Idempotency-Key`) mà máy chủ sẽ lưu trữ cùng với kết quả trong vòng 24 giờ hoặc lâu hơn. Khởi tạo khóa này một lần duy nhất trước lần thử đầu tiên, và tái sử dụng lại nó cho mọi lần retry.
- Các tác vụ thanh toán, gửi email, SMS, tạo đơn hàng và giữ chỗ tồn kho tuyệt đối không bao giờ được retry nếu không có khóa bất biến.

## Danh sách kiểm tra cho Client (Client checklist)
1. Đã thiết lập per-try timeout và ngắn hơn timeout của bên gọi.
2. Đã thiết lập hạn chót tổng thể (overall deadline) và truyền xuống dọc theo chuỗi lệnh gọi.
3. Sử dụng exponential backoff có giới hạn trần (cap) và full jitter.
4. Số lần thử (attempts) <= 3 ở tầng biên (edge), 1-2 ở các tầng bên trong.
5. Chỉ retry trên các hàng có giá trị "Có" trong bảng hướng dẫn.
6. Đính kèm khóa bất biến (idempotency key) trên mọi lệnh gọi không bất biến có thực hiện retry.
7. Có ngân sách retry (retry budget) hoặc bộ ngắt mạch (circuit breaker) để dừng retry khi xảy ra sự cố diện rộng.
8. Các chỉ số đo lường (metrics): số lần thử trên mỗi yêu cầu, tỷ lệ retry, số lần từ bỏ (give-up count), phân loại theo từng dịch vụ phụ thuộc (dependency).
9. Chỉ ghi log lỗi cuối cùng kèm theo tổng số lần đã thử, không ghi log ở mức error cho từng lần thử thất bại riêng lẻ.

## Kiến thức cơ bản về Bộ ngắt mạch (Circuit breaker)
- Đóng (Closed): các lệnh gọi diễn ra bình thường; đếm số lỗi trong một cửa sổ trượt (rolling window).
- Mở (Open): sau khi vượt ngưỡng ví dụ như 50% lỗi trên ít nhất 20 lệnh gọi, báo lỗi ngay lập tức (fail fast) trong vòng 30 giây.
- Nửa mở (Half-open): cho phép một vài lệnh gọi thăm dò đi qua; chuyển về trạng thái Đóng nếu chúng thành công.
Sự kết hợp giữa bộ ngắt mạch và ngân sách retry sẽ ngăn chặn bão retry làm trầm trọng thêm sự cố sập hệ thống.

## Thiết lập của các thư viện phổ biến
- AWS SDKs: chế độ tiêu chuẩn (standard mode) = 3 lần thử (attempts), full jitter, độ trễ cơ sở (base) 1 giây khi bị giới hạn tốc độ (throttling); chế độ thích ứng (adaptive mode) bổ sung thêm tính năng giới hạn tốc độ phía client.
- Chính sách retry của gRPC: maxAttempts, initialBackoff, maxBackoff, backoffMultiplier, retryableStatusCodes (UNAVAILABLE là mã phổ biến nhất); việc retry cần tuân thủ các quy tắc hedging/transparent retry đối với các phương thức không bất biến.
- Envoy / Istio: retry_on: 5xx,reset,connect-failure; num_retries; per_try_timeout; thiết lập route timeout >= per_try_timeout x (num_retries + 1).
- Python `urllib3.Retry(total=3, backoff_factor=0.5, status_forcelist=[502,503,504], respect_retry_after_header=True, allowed_methods=[...])`.
Hãy kiểm tra tài liệu của thư viện tương ứng với phiên bản bạn đang sử dụng; các giá trị mặc định có thể thay đổi theo phiên bản.
FILE:templates/retry-policy-review.md
# Đánh giá Chính sách Retry - <dịch vụ / chuỗi lệnh gọi> - <ngày>

**Người đánh giá:** <tên>  **Đầu vào:** <JSON chuỗi / các tệp cấu hình>  **Tỷ lệ lỗi giả định:** <30%>

## Chuỗi lệnh gọi
| # | Bên gọi -> Bên được gọi | Số lần thử (Attempts) | Backoff (base / x / cap) | Jitter | Per-try timeout | Deadline | Trường hợp xấu nhất |
|---|---|---|---|---|---|---|---|
| 1 | | | | | | | |

**Mức độ khuếch đại trường hợp xấu nhất:** <36> lệnh gọi trên mỗi yêu cầu (mục tiêu <= 10)
**Mức độ khuếch đại kỳ vọng tại <p>:** <2.7>

## Các phát hiện
| Mức độ nghiêm trọng | Tầng | Mã lỗi | Vấn đề | Cách khắc phục |
|---|---|---|---|---|
| high | | | | |

## Chính sách đề xuất
| # | Bên gọi -> Bên được gọi | Số lần thử (Attempts) | Backoff | Jitter | Per-try timeout | Deadline | Retry khi gặp |
|---|---|---|---|---|---|---|---|
| 1 | | | | full | | | |

**Mức độ khuếch đại trường hợp xấu nhất mới:** <n>  **Độ trễ trường hợp xấu nhất mới:** <s>

## Công việc tiếp theo
- [ ] Bổ sung khóa bất biến (idempotency keys) cho: <các lệnh gọi>
- [ ] Thiết lập ngân sách retry / bộ ngắt mạch cho: <dịch vụ phụ thuộc>
- [ ] Bảng điều khiển (Dashboards): số lần thử trên mỗi yêu cầu, tỷ lệ retry, số lần từ bỏ
FILE:examples/checkout-chain.json
{
  "name": "checkout: browser -> api gateway -> orders service -> payments provider",
  "failure_rate": 0.3,
  "max_amplification": 10,
  "layers": [
    {
      "name": "web app -> api gateway",
      "attempts": 3,
      "base_delay_ms": 200,
      "multiplier": 2,
      "max_delay_ms": 2000,
      "jitter": "full",
      "per_try_timeout_ms": 8000,
      "deadline_ms": 20000,
      "retry_on": ["network", 502, 503, 504],
      "idempotent": true,
      "respect_retry_after": true
    },
    {
      "name": "api gateway -> orders service",
      "attempts": 3,
      "base_delay_ms": 100,
      "multiplier": 2,
      "max_delay_ms": 1000,
      "jitter": "none",
      "per_try_timeout_ms": 3000,
      "retry_on": [500, 502, 503, 504, "timeout"],
      "idempotent": true
    },
    {
      "name": "orders service -> payments provider (create charge)",
      "attempts": 4,
      "base_delay_ms": 250,
      "multiplier": 2,
      "jitter": "equal",
      "per_try_timeout_ms": 2500,
      "deadline_ms": 9000,
      "retry_on": [409, 429, 500, 503, "timeout"],
      "idempotent": false,
      "idempotency_key": false,
      "respect_retry_after": false
    }
  ]
}
FILE:scripts/retry_policy.py
#!/usr/bin/env python3
"""Retry and backoff policy calculator (Python 3 standard library only).

Input: a JSON file describing a call chain, outermost caller first:
  {"name": "checkout", "failure_rate": 0.3, "max_amplification": 10,
   "layers": [
     {"name": "web -> api", "attempts": 3, "base_delay_ms": 100, "multiplier": 2,
      "max_delay_ms": 2000, "jitter": "full", "per_try_timeout_ms": 5000,
      "deadline_ms": 12000, "retry_on": [502, 503, 504, "timeout"],
      "idempotent": true, "respect_retry_after": true}, ...]}
attempts = total tries including the first one (attempts 1 = no retries).
jitter: none | full | equal | decorrelated.  Delay before retry k (k = 1..attempts-1):
  none  d = min(cap, base * multiplier^(k-1))
  full  U(0, d)              equal  d/2 + U(0, d/2)
  decorrelated  min(cap, U(base, 3 * previous))  (AWS style; expected value simulated, seed 7)

Reports per layer: delay schedule (min / expected / max), worst-case time for one call
(attempts x per-try timeout + max delays, cut at deadline), expected tries at the given
failure rate, and for the chain: worst-case and expected request amplification at the
innermost dependency. Flags:
  NO_JITTER, NO_CAP, HOT_LOOP (base 0), TOO_MANY_ATTEMPTS (> 5), NO_PER_TRY_TIMEOUT,
  NO_DEADLINE, DEADLINE_TOO_SHORT (deadline < one per-try timeout), INNER_OUTLIVES_CALLER
  (worst case > the outer layer's per-try timeout), RETRY_UNSAFE (non-idempotent with
  retries and no idempotency key), RETRY_ON_4XX (retrying 400/401/403/404/409/422),
  IGNORES_RETRY_AFTER (retries 429/503 without honoring Retry-After), AMPLIFICATION.
Usage:
  python3 retry_policy.py chain.json
  python3 retry_policy.py chain.json --failure-rate 0.5 --json
  python3 retry_policy.py --self-test
Exit code: 0 OK, 1 high-severity flags found, 2 bad input.
"""
import argparse
import json
import random
import sys

JITTERS = ("none", "full", "equal", "decorrelated")
BAD_4XX = {400, 401, 403, 404, 405, 409, 410, 422}
SEVERITY = {"INNER_OUTLIVES_CALLER": "high", "RETRY_UNSAFE": "high", "AMPLIFICATION": "high",
            "HOT_LOOP": "high", "NO_PER_TRY_TIMEOUT": "high", "RETRY_ON_4XX": "medium",
            "NO_JITTER": "medium", "NO_CAP": "medium", "IGNORES_RETRY_AFTER": "medium",
            "NO_DEADLINE": "medium", "DEADLINE_TOO_SHORT": "medium", "TOO_MANY_ATTEMPTS": "low"}


class PolicyError(ValueError):
    pass


def num(layer, key, default=None, minimum=0):
    v = layer.get(key, default)
    if v is None:
        return None
    if isinstance(v, bool) or not isinstance(v, (int, float)):
        raise PolicyError(f"layer {layer.get('name', '?')!r}: {key} must be a number")
    if v < minimum:
        raise PolicyError(f"layer {layer.get('name', '?')!r}: {key} must be >= {minimum}")
    return v


def delays(layer):
    """Return [(min, expected, max)] in ms for each retry."""
    n = int(layer["attempts"])
    base, mult, cap = layer["base_delay_ms"], layer["multiplier"], layer["max_delay_ms"]
    capf = (lambda x: x) if cap is None else (lambda x: min(cap, x))
    j = layer["jitter"]
    out = []
    if j == "decorrelated":
        rng = random.Random(7)
        runs = 20000
        sums = [0.0] * (n - 1)
        for _ in range(runs):
            prev = base
            for k in range(n - 1):
                prev = capf(rng.uniform(base, prev * 3)) if base > 0 else 0.0
                sums[k] += prev
        hi = base
        for k in range(n - 1):
            hi = capf(hi * 3)
            out.append((capf(base), sums[k] / runs, hi))
        return out
    for k in range(n - 1):
        d = capf(base * mult ** k)
        if j == "none":
            out.append((d, d, d))
        elif j == "full":
            out.append((0.0, d / 2, d))
        else:
            out.append((d / 2, d * 0.75, d))
    return out


def normalize(raw):
    layers = raw.get("layers") if isinstance(raw, dict) else None
    if not layers or not isinstance(layers, list):
        raise PolicyError("JSON must have a non-empty 'layers' list (outermost caller first)")
    out = []
    for i, l in enumerate(layers):
        if not isinstance(l, dict):
            raise PolicyError(f"layer {i} must be an object")
        l = dict(l)
        l.setdefault("name", f"layer {i + 1}")
        a = num(l, "attempts", 1, 1)
        if int(a) != a:
            raise PolicyError(f"layer {l['name']!r}: attempts must be a whole number")
        l["attempts"] = int(a)
        l["base_delay_ms"] = num(l, "base_delay_ms", 0)
        l["multiplier"] = num(l, "multiplier", 2, 1)
        l["max_delay_ms"] = num(l, "max_delay_ms", None)
        l["per_try_timeout_ms"] = num(l, "per_try_timeout_ms", None, 1)
        l["deadline_ms"] = num(l, "deadline_ms", None, 1)
        j = str(l.get("jitter", "none")).lower()
        if j not in JITTERS:
            raise PolicyError(f"layer {l['name']!r}: jitter must be one of {', '.join(JITTERS)}")
        l["jitter"] = j
        l["retry_on"] = list(l.get("retry_on", []))
        l["idempotent"] = bool(l.get("idempotent", True))
        l["idempotency_key"] = bool(l.get("idempotency_key", False))
        l["respect_retry_after"] = bool(l.get("respect_retry_after", False))
        out.append(l)
    return out


def expected_tries(n, p):
    if p >= 1:
        return float(n)
    return sum(p ** k for k in range(n))


def analyze(raw, failure_rate=None, max_amp=None):
    layers = normalize(raw)
    p = failure_rate if failure_rate is not None else raw.get("failure_rate", 0.2)
    if not 0 <= p <= 1:
        raise PolicyError("failure rate must be between 0 and 1")
    max_amp = max_amp if max_amp is not None else raw.get("max_amplification", 10)
    res = {"name": raw.get("name", "retry policy"), "failure_rate": p, "max_amplification": max_amp, "layers": []}
    worst_amp, exp_amp = 1, 1.0
    for i, l in enumerate(layers):
        n = l["attempts"]
        ds = delays(l)
        flags = []
        retries = n > 1
        if retries and l["jitter"] == "none":
            flags.append(("NO_JITTER", "all clients retry at the same moments; use full or decorrelated jitter"))
        if retries and l["max_delay_ms"] is None and n > 3:
            flags.append(("NO_CAP", "no max_delay_ms; exponential delays grow without bound"))
        if retries and l["base_delay_ms"] == 0:
            flags.append(("HOT_LOOP", "base_delay_ms is 0; retries fire immediately and hammer the dependency"))
        if n > 5:
            flags.append(("TOO_MANY_ATTEMPTS", f"{n} attempts; 3 (2 retries) is usually enough"))
        if l["per_try_timeout_ms"] is None:
            flags.append(("NO_PER_TRY_TIMEOUT", "no per-try timeout; one hung call can block every retry"))
        if l["deadline_ms"] is None and retries:
            flags.append(("NO_DEADLINE", "no overall deadline across attempts"))
        if l["deadline_ms"] is not None and l["per_try_timeout_ms"] and l["deadline_ms"] < l["per_try_timeout_ms"]:
            flags.append(("DEADLINE_TOO_SHORT", "deadline is shorter than a single try"))
        if retries and not l["idempotent"] and not l["idempotency_key"]:
            flags.append(("RETRY_UNSAFE", "retries a non-idempotent call without an idempotency key (double charge / duplicate write risk)"))
        bad = sorted(c for c in l["retry_on"] if isinstance(c, int) and c in BAD_4XX)
        if retries and bad:
            flags.append(("RETRY_ON_4XX", f"retries client errors {bad}; they will fail the same way again"))
        if retries and any(c in (429, 503) for c in l["retry_on"]) and not l["respect_retry_after"]:
            flags.append(("IGNORES_RETRY_AFTER", "retries 429/503 but ignores the Retry-After header"))
        max_delay_total = sum(d[2] for d in ds)
        exp_delay_total = sum(d[1] for d in ds)
        worst = None
        if l["per_try_timeout_ms"] is not None:
            worst = n * l["per_try_timeout_ms"] + max_delay_total
            if l["deadline_ms"] is not None:
                worst = min(worst, l["deadline_ms"])
        elif l["deadline_ms"] is not None:
            worst = l["deadline_ms"]
        if i > 0:
            parent = layers[i - 1]
            pt = parent["per_try_timeout_ms"]
            if pt is not None and (worst is None or worst > pt):
                flags.append(("INNER_OUTLIVES_CALLER", f"worst case {fmt_ms(worst)} > caller per-try timeout {fmt_ms(pt)}; the caller gives up and retries while this layer is still working"))
        et = expected_tries(n, p)
        worst_amp *= n
        exp_amp *= et
        res["layers"].append({
            "name": l["name"], "attempts": n, "jitter": l["jitter"],
            "delays_ms": [[round(a, 1), round(b, 1), round(c, 1)] for a, b, c in ds],
            "max_delay_total_ms": round(max_delay_total, 1), "expected_delay_total_ms": round(exp_delay_total, 1),
            "per_try_timeout_ms": l["per_try_timeout_ms"], "deadline_ms": l["deadline_ms"],
            "worst_case_ms": None if worst is None else round(worst, 1),
            "expected_tries": round(et, 3), "cumulative_worst_amplification": worst_amp,
            "cumulative_expected_amplification": round(exp_amp, 3),
            "flags": [{"code": c, "severity": SEVERITY[c], "detail": d} for c, d in flags]})
    if worst_amp > max_amp:
        res["layers"][-1]["flags"].append({"code": "AMPLIFICATION", "severity": "high",
            "detail": f"one top-level request can become {worst_amp} calls to the innermost dependency during an outage (limit {max_amp}); retry at one layer only or add a retry budget"})
    res["worst_amplification"] = worst_amp
    res["expected_amplification"] = round(exp_amp, 3)
    res["high_flags"] = sum(1 for l in res["layers"] for f in l["flags"] if f["severity"] == "high")
    return res


def fmt_ms(v):
    if v is None:
        return "unbounded"
    if v >= 1000:
        s = f"{v / 1000:.2f}".rstrip("0").rstrip(".")
        return s + " s"
    return f"{v:g} ms"


def to_markdown(r):
    out = [f"# Retry policy report: {r['name']}", "",
           f"Failure rate used for expected values: {r['failure_rate']:.0%}", "",
           "| Layer | Attempts | Jitter | Max total delay | Per-try timeout | Deadline | Worst case | Expected tries |",
           "|---|---|---|---|---|---|---|---|"]
    for l in r["layers"]:
        out.append(f"| {l['name']} | {l['attempts']} | {l['jitter']} | {fmt_ms(l['max_delay_total_ms'])} | "
                   f"{fmt_ms(l['per_try_timeout_ms'])} | {fmt_ms(l['deadline_ms'])} | {fmt_ms(l['worst_case_ms'])} | {l['expected_tries']:g} |")
    out += ["", "## Delay schedule (min / expected / max)"]
    for l in r["layers"]:
        if not l["delays_ms"]:
            out.append(f"- {l['name']}: no retries")
            continue
        parts = [f"retry {k + 1}: {fmt_ms(a)} / {fmt_ms(b)} / {fmt_ms(c)}" for k, (a, b, c) in enumerate(l["delays_ms"])]
        out.append(f"- {l['name']}: " + "; ".join(parts))
    out += ["", "## Amplification at the innermost dependency",
            f"- Worst case (every try fails): {r['worst_amplification']} calls per top-level request",
            f"- Expected at {r['failure_rate']:.0%} failure rate: {r['expected_amplification']:g} calls per top-level request", "",
            "## Findings"]
    n = 0
    for sev in ("high", "medium", "low"):
        for l in r["layers"]:
            for f in l["flags"]:
                if f["severity"] == sev:
                    n += 1
                    out.append(f"- **{sev.upper()} {f['code']}** ({l['name']}): {f['detail']}")
    if not n:
        out.append("- No findings.")
    return "\n".join(out)


def self_test():
    cases = []

    def ok(name, cond):
        cases.append((name, bool(cond)))

    def one(**kw):
        base = {"name": "x", "attempts": 3, "base_delay_ms": 100, "multiplier": 2, "max_delay_ms": 1000,
                "jitter": "full", "per_try_timeout_ms": 1000, "deadline_ms": 5000, "retry_on": [503],
                "respect_retry_after": True}
        base.update(kw)
        return base

    def codes(r, i=0):
        return {f["code"] for f in r["layers"][i]["flags"]}

    d = delays(normalize({"layers": [one(jitter="none", attempts=5)]})[0])
    ok("exponential no jitter 100,200,400,800", [x[2] for x in d] == [100, 200, 400, 800])
    d = delays(normalize({"layers": [one(jitter="none", attempts=6)]})[0])
    ok("cap applied at 1000", d[-1][2] == 1000 and d[-2][2] == 800)
    d = delays(normalize({"layers": [one(jitter="full")]})[0])
    ok("full jitter mean half", d[0] == (0.0, 50.0, 100))
    d = delays(normalize({"layers": [one(jitter="equal")]})[0])
    ok("equal jitter 50..100 mean 75", d[0] == (50.0, 75.0, 100))
    d = delays(normalize({"layers": [one(jitter="decorrelated", attempts=4)]})[0])
    ok("decorrelated within bounds", all(a <= b <= c for a, b, c in d) and d[0][2] == 300 and d[2][2] == 1000)
    r = analyze({"layers": [one()]})
    ok("clean policy has no flags", not codes(r))
    ok("worst case = 3x1000 + 100 + 200", r["layers"][0]["worst_case_ms"] == 3300)
    r = analyze({"layers": [one(deadline_ms=2000)]})
    ok("deadline caps worst case", r["layers"][0]["worst_case_ms"] == 2000)
    ok("expected tries p=0.5 n=3 is 1.75", expected_tries(3, 0.5) == 1.75 and expected_tries(3, 1) == 3)
    ok("no jitter flagged", "NO_JITTER" in codes(analyze({"layers": [one(jitter="none")]})))
    ok("hot loop flagged", "HOT_LOOP" in codes(analyze({"layers": [one(base_delay_ms=0)]})))
    ok("no cap flagged with 4+ attempts", "NO_CAP" in codes(analyze({"layers": [one(max_delay_ms=None, attempts=4)]})))
    ok("unsafe retry flagged", "RETRY_UNSAFE" in codes(analyze({"layers": [one(idempotent=False)]})))
    ok("idempotency key makes it safe", "RETRY_UNSAFE" not in codes(analyze({"layers": [one(idempotent=False, idempotency_key=True)]})))
    ok("4xx retry flagged", "RETRY_ON_4XX" in codes(analyze({"layers": [one(retry_on=[400, 503])]})))
    ok("Retry-After ignored flagged", "IGNORES_RETRY_AFTER" in codes(analyze({"layers": [one(retry_on=[429], respect_retry_after=False)]})))
    ok("single attempt is not a retry policy", not codes(analyze({"layers": [one(attempts=1, jitter="none", deadline_ms=None)]})))
    r = analyze({"layers": [one(per_try_timeout_ms=2000, deadline_ms=None), one(per_try_timeout_ms=1000, deadline_ms=None)]})
    ok("inner outlives caller flagged", "INNER_OUTLIVES_CALLER" in codes(r, 1))
    r = analyze({"layers": [one(per_try_timeout_ms=4000), one(per_try_timeout_ms=1000, deadline_ms=3500)]})
    ok("inner within caller timeout ok", "INNER_OUTLIVES_CALLER" not in codes(r, 1))
    r = analyze({"layers": [one(), one(), one()], "max_amplification": 10})
    ok("3x3x3 amplification 27 flagged", r["worst_amplification"] == 27 and "AMPLIFICATION" in codes(r, 2))
    r = analyze({"layers": [one(), one()]}, failure_rate=0.5)
    ok("expected amplification 1.75^2", r["expected_amplification"] == 3.062)
    for bad, label in ((({"layers": []}), "empty layers rejected"), (({"layers": [one(jitter="random")]}), "bad jitter rejected"),
                       (({"layers": [one(attempts=0)]}), "zero attempts rejected"), (({"layers": [one(attempts=2.5)]}), "fractional attempts rejected")):
        try:
            analyze(bad)
            ok(label, False)
        except PolicyError:
            ok(label, True)
    ok("fmt_ms", fmt_ms(1500) == "1.5 s" and fmt_ms(250) == "250 ms" and fmt_ms(None) == "unbounded")
    passed = sum(c for _, c in cases)
    for name, c in cases:
        print(("PASS " if c else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Check retry/backoff/timeout policies across a call chain.")
    ap.add_argument("file", nargs="?", help="chain JSON ('-' for stdin)")
    ap.add_argument("--failure-rate", type=float, help="probability that one try fails (default from file or 0.2)")
    ap.add_argument("--max-amplification", type=float, help="flag chains above this worst-case amplification (default 10)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.file:
        ap.error("chain JSON required (or --self-test)")
    try:
        raw = json.load(sys.stdin if a.file == "-" else open(a.file, encoding="utf-8"))
        r = analyze(raw, a.failure_rate, a.max_amplification)
    except (OSError, PolicyError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else to_markdown(r))
    return 1 if r["high_flags"] else 0


if __name__ == "__main__":
    sys.exit(main())
BackendAPIPython+2
F@f
0
Công cụ Dự báo Thời gian Cạn kiệt Dung lượng Ổ đĩa
Kỹ năng

Dự báo thời điểm các ổ đĩa và phân vùng (volume) sẽ đầy dựa trên các mẫu dữ liệu mức sử dụng (tệp CSV gồm dấu thời gian, phân vùng, dung lượng đã dùng và tổng dung lượng). Ước lượng tốc độ tăng trưởng tổng thể và trong tuần gần nhất, loại bỏ các bước nhảy đột biến một lần khỏi xu hướng, đồng thời đưa ra số ngày và ngày cụ thể chạm ngưỡng đầy 80, 90, 95 và 100% cùng mức dung lượng đề xuất để duy trì khoảng dự phòng 90 ngày. Cảnh báo các phân vùng sắp đầy, tốc độ tăng trưởng đang tăng tốc, các bước nhảy đột ngột, xu hướng nhiễu và dữ liệu quá mỏng. Bao gồm công cụ dự báo viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.

---
name: disk-capacity-runway-forecaster
description: Dự báo thời điểm các ổ đĩa và phân vùng (volume) sẽ đầy dựa trên các mẫu dữ liệu mức sử dụng (tệp CSV gồm dấu thời gian, phân vùng, dung lượng đã dùng và tổng dung lượng). Ước lượng tốc độ tăng trưởng tổng thể và trong tuần gần nhất, loại bỏ các bước nhảy đột biến một lần khỏi xu hướng, đồng thời đưa ra số ngày và ngày cụ thể chạm ngưỡng đầy 80, 90, 95 và 100% cùng mức dung lượng đề xuất để duy trì khoảng dự phòng 90 ngày. Cảnh báo các phân vùng sắp đầy, tốc độ tăng trưởng đang tăng tốc, các bước nhảy đột ngột, xu hướng nhiễu và dữ liệu quá mỏng. Bao gồm công cụ dự báo viết bằng thư viện chuẩn Python kèm tùy chọn --self-test.
---

# Công cụ Dự báo Thời gian Cạn kiệt Dung lượng Ổ đĩa

Bạn giúp các kỹ sư trực ca (on-call) và quản trị viên hệ thống trả lời câu hỏi "khi nào ổ đĩa này sẽ đầy, và chúng ta nên làm gì trước thời điểm đó?". Từ các mẫu dữ liệu mức sử dụng được xuất ra từ hệ thống giám sát (Prometheus, CloudWatch, Datadog, Zabbix, hoặc một cron job chạy lệnh `df`), bạn tính toán thời gian còn lại (runway) cho từng phân vùng, xếp hạng các phân vùng theo mức độ khẩn cấp, giải thích lý do vì sao dự báo có thể sai lệch, và đề xuất các biện pháp xử lý cụ thể: dọn dẹp, xoay vòng nhật ký (rotate), mở rộng dung lượng (resize) hoặc di chuyển dữ liệu.

## Các tệp trong kỹ năng này

- `scripts/disk_runway.py` - đọc tệp CSV chứa các mẫu dữ liệu, ước lượng tốc độ tăng trưởng và in ra bảng xếp hạng thời gian cạn kiệt dung lượng kèm theo các phát hiện và kích thước đề xuất (chỉ dùng thư viện chuẩn Python 3)
- `references/forecasting-method.md` - cách tính xu hướng, tốc độ gần đây, các bước nhảy đột biến một lần, thời gian còn lại và kích thước đề xuất, cùng những giới hạn của phương pháp dự báo tuyến tính
- `references/disk-full-runbook.md` - cách tìm nguyên nhân gây tăng dung lượng, các bước dọn dẹp an toàn, chính sách lưu trữ nhật ký và bản sao lưu, cách mở rộng dung lượng trên Linux và trên đám mây, ngưỡng cảnh báo
- `templates/capacity-review.md` - mẫu báo cáo đánh giá dung lượng để chia sẻ với nhóm hoặc đính kèm vào ticket
- `examples/fleet-disk-usage.csv` - các mẫu dữ liệu hàng ngày trong ba tuần của bốn phân vùng: một cơ sở dữ liệu tăng trưởng ổn định, một phân vùng chứa log có xoay vòng, một CI runner đang tăng tốc độ tiêu thụ và một thiết bị lưu trữ sao lưu NAS có một bước nhảy đột biến một lần

## Quy trình làm việc

### 1. Thu thập các mẫu dữ liệu
Yêu cầu cung cấp các mẫu dữ liệu của ít nhất một tuần (tốt nhất là 3-4 tuần) cho mỗi phân vùng: dấu thời gian (timestamp), phân vùng (host:mount), số GB đã dùng và tổng số GB kích thước. Ví dụ về các nguồn dữ liệu: Prometheus `node_filesystem_size_bytes - node_filesystem_avail_bytes`, CloudWatch `disk_used_percent` kết hợp với kích thước phân vùng, hoặc lệnh `df -BG --output=target,used,size` từ cron. Đồng thời hỏi xem dữ liệu gì đang nằm trên mỗi phân vùng và các sự kiện đã biết (di chuyển dữ liệu, bản sao lưu mới, đợt phát hành mới).

### 2. Chạy công cụ dự báo
```
python3 scripts/disk_runway.py examples/fleet-disk-usage.csv
python3 scripts/disk_runway.py usage.csv --horizon 180 --json
python3 scripts/disk_runway.py --self-test
```
Mã thoát (exit code) 1 nghĩa là có phân vùng ở mức CRITICAL hoặc HIGH, 2 nghĩa là dữ liệu đầu vào bị lỗi.

### 3. Giải thích và hành động
Bắt đầu với các phân vùng ở mức CRITICAL và HIGH. Với mỗi phân vùng, hãy nêu rõ thứ gì đang phình to và với tốc độ bao nhiêu, ngày nào sẽ chạm ngưỡng 90% và 100%, cùng hành động an toàn nhanh nhất từ `references/disk-full-runbook.md`. Hãy xem các kết quả có gắn cờ NOISY_TREND và FEW_SAMPLES là ước tính sơ bộ. Đối với SUDDEN_JUMP, hãy xác nhận nguyên nhân (một bộ sao lưu mới, một bản kết xuất cơ sở dữ liệu, một đợt bùng nổ log) trước khi lập kế hoạch xoay quanh nó. Sử dụng `references/forecasting-method.md` để giải thích lý do vì sao tốc độ gần đây có thể được ưu tiên áp dụng hơn xu hướng dài hạn.

### 4. Bàn giao kết quả
Điền vào `templates/capacity-review.md` bảng số liệu, các hành động cần làm, người phụ trách và thời hạn, đồng thời đề xuất các ngưỡng cảnh báo (ví dụ: cảnh báo warning ở mức 80% hoặc còn 14 ngày dung lượng, gọi trực khẩn cấp page ở mức 90% hoặc còn 3 ngày).

## Kết quả ví dụ
Đối với `examples/fleet-disk-usage.csv` (mẫu cuối cùng lúc 2026-10-21 06:00): ci-runner01:/ ở mức HIGH, đã đầy 80% và đang tăng 5.6 GB/ngày trong tuần qua so với 2.1 tổng thể, sẽ chạm 90% sau 3.6 ngày và đầy hoàn toàn vào ngày 2026-10-28; db01:/var/lib/postgresql tăng đều đặn 3 GB/ngày và chạm ngưỡng 80% sau 13.3 ngày (mức MEDIUM, dung lượng đề xuất 790 GB); backup-nas có một bước nhảy đột biến một lần +405.6 GB vào ngày 2026-10-16 không bị ngoại suy tiếp, còn 120 ngày nữa mới tới ngưỡng 80%; web01:/var/log ở mức OK nhưng dữ liệu nhiễu (do xoay vòng log).

## Quy tắc

- Không bao giờ gợi ý xóa dữ liệu mà không nêu chính xác cần xóa gì, cách kiểm tra tính an toàn và cách khôi phục; hãy ưu tiên xoay vòng log, nén dữ liệu, áp dụng chính sách lưu giữ (retention) và mở rộng dung lượng.
- Nêu rõ ngày dự báo được tính dựa trên tốc độ nào và lưu ý rằng các dự báo tuyến tính sẽ không tính đến các sự kiện đột xuất.
- Thời gian trong báo cáo tuân theo các dấu thời gian trong dữ liệu; hãy nói rõ chúng đang ở múi giờ nào.
- Không tự mình chạy các lệnh dọn dẹp hoặc mở rộng dung lượng trên hệ thống đang hoạt động (live systems); hãy cung cấp câu lệnh để người dùng tự kiểm tra và thực thi.
FILE:references/forecasting-method.md
# Phương pháp Dự báo

## Đầu vào
Mỗi mẫu dữ liệu là một dòng: `timestamp,volume,used_gb,size_gb`. Các dòng có thể chưa được sắp xếp và trộn lẫn giữa nhiều phân vùng. Giá trị `size_gb` mới nhất sẽ được sử dụng, nhờ đó việc thay đổi kích thước trong khoảng thời gian lấy mẫu vẫn được xử lý chính xác.

## Tốc độ tăng trưởng
- **Tốc độ tổng thể (Overall rate):** hệ số góc bình phương tối thiểu (least-squares slope) của số GB đã dùng theo thời gian (GB/ngày) trên toàn bộ các mẫu dữ liệu. Hệ số R^2 (từ 0 đến 1) cho biết mức độ khớp của đường thẳng: xấp xỉ 1.0 đối với mức tăng trưởng đều đặn, dưới 0.5 đối với các mẫu hình răng cưa như xoay vòng log hoặc tệp tạm.
- **Tốc độ gần đây (Recent rate):** cùng hệ số góc đó nhưng tính trên 7 ngày gần nhất (hoặc một phần ba dữ liệu cuối cùng khi tổng thời gian ít hơn 14 ngày). Chỉ số này giúp phát hiện mức tăng trưởng vừa mới tăng tốc.
- **Tốc độ tính thời gian cạn kiệt (Runway rate):** lấy giá trị xấu hơn (cao hơn) giữa hai tốc độ trên. Khi tốc độ gần đây lớn hơn 1.5 lần tốc độ tổng thể, phân vùng đó sẽ được gắn cờ ACCELERATING (đang tăng tốc).

## Các bước nhảy đột biến một lần (One-off jumps)
Một bước nhảy giữa hai mẫu dữ liệu lớn hơn 5% kích thước phân vùng được coi là một sự kiện đơn lẻ (một bộ sao lưu mới, một bản dump vừa khôi phục, một tệp tải lên kích thước lớn). Phần dung lượng này vẫn được giữ nguyên trong mức sử dụng hiện tại nhưng được loại bỏ khỏi xu hướng tăng trưởng: các điểm dữ liệu phía sau được dịch xuống một khoảng bằng độ lớn bước nhảy trừ đi mức tăng thông thường, nhằm tránh việc ngoại suy một sự kiện đơn lẻ kéo dài mãi mãi. Bước nhảy này được báo cáo là SUDDEN_JUMP để con người có thể xác nhận xem nó có thực sự là sự kiện một lần hay không. Nếu sự kiện này lặp lại (chẳng hạn sao lưu toàn bộ hàng tuần), hãy thu thập dữ liệu dài hơn để quy luật đó trở thành một phần của xu hướng, hoặc chủ động tính riêng bước nhảy tiếp theo vào kế hoạch.

## Thời gian còn lại (Runway)
Đối với mỗi ngưỡng T trong các mức 80, 90, 95 và 100%:
`số ngày = (T x kích thước - mức dùng hiện tại) / tốc độ tính thời gian cạn kiệt`, được tính từ mẫu dữ liệu cuối cùng. Giá trị bằng 0 nghĩa là ngưỡng đó đã bị vượt qua; "never" (không bao giờ) nghĩa là mức sử dụng đang đi ngang hoặc giảm xuống.
Ngày dự kiến bằng thời điểm của mẫu dữ liệu cuối cùng cộng thêm số ngày còn lại, theo cùng múi giờ với dữ liệu gốc.

## Kích thước đề xuất
Bội số nhỏ nhất của 10 GB giúp giữ mức sử dụng dưới 80% trong toàn bộ khung thời gian dự báo (horizon, mặc định là 90 ngày):
`kích thước >= (mức dùng hiện tại + tốc độ tính thời gian cạn kiệt x khung thời gian) / 0.8`, và không bao giờ nhỏ hơn kích thước hiện tại.
Ví dụ: đã dùng 128 GB, tăng 2 GB/ngày, khung thời gian 180 ngày -> (128 + 360) / 0.8 = 610 GB.

## Các mức trạng thái
| Trạng thái | Quy tắc |
|---|---|
| CRITICAL | Đã dùng từ 95% trở lên, hoặc sẽ đầy trong vòng 7 ngày |
| HIGH | Chạm ngưỡng 90% trong vòng 14 ngày |
| MEDIUM | Chạm ngưỡng 80% trong vòng 30 ngày, hoặc đang tăng tốc, hoặc có bước nhảy đột ngột |
| OK | Không thuộc các trường hợp trên |

## Những giới hạn
- Một đường thẳng không thể nhìn thấy các sự kiện trong tương lai: một đợt di chuyển dữ liệu, một khách hàng mới, hay mức ghi log bị bỏ quên ở chế độ DEBUG.
- Hệ thống tệp (filesystem) thường gặp trục trặc trước khi chạm mức 100%: ext4 dành riêng khoảng 5% cho quyền root, cơ sở dữ liệu cần không gian trống để chạy vacuum, nén dữ liệu (compaction) hoặc xây dựng chỉ mục (index), và một số luồng ghi dữ liệu đã bắt đầu lỗi ở mức 90-95%.
- Cạn kiệt inode (`df -i`) có thể làm đầy ổ đĩa ngay cả khi vẫn còn trống rất nhiều GB; hãy dự báo số lượng inode theo cách tương tự nếu hệ thống ghi nhiều tệp nhỏ.
- Lưu trữ cấp phát mỏng (thin-provisioned) và lưu trữ dùng chung (LVM thin pools, SAN, Ceph) có thể bị đầy ở tầng bên dưới ngay cả khi phân vùng bên trên trông vẫn còn trống.
FILE:references/disk-full-runbook.md
# Sổ tay Xử lý Ổ đĩa Đầy (Disk Full Runbook)

## 1. Tìm nguyên nhân gây tăng dung lượng
```
df -h /mount; df -i /mount                       # dung lượng và số inode
sudo du -xh --max-depth=2 /mount | sort -h | tail -20
sudo find /mount -xdev -type f -size +1G -mtime -7 -exec ls -lh {} +
sudo lsof +L1                                    # các tệp đã xóa nhưng vẫn đang được tiến trình giữ mở
journalctl --disk-usage
docker system df                                 # images, containers, volumes, build cache
```
Các tệp đã xóa nhưng vẫn đang mở sẽ tiếp tục chiếm dung lượng cho đến khi tiến trình khởi động lại hoặc mở lại tệp log.

## 2. Các giải pháp xử lý nhanh an toàn (kiểm tra kỹ trước khi xóa)
| Nguồn tiêu thụ | Hành động |
|---|---|
| Nhật ký ứng dụng (Application logs) | cấu hình logrotate với `compress`, `maxsize`, `rotate N`; đẩy log về hệ thống lưu trữ tập trung |
| Nhật ký systemd (systemd journal) | chạy `journalctl --vacuum-size=1G`, thiết lập `SystemMaxUse=` |
| Docker / CI runners | chạy `docker image prune -a --filter until=168h`, `docker builder prune`; dọn sạch các thư mục làm việc của runner |
| Bộ nhớ đệm gói cài đặt (Package caches) | chạy `apt-get clean`, `dnf clean all`, xóa các bản nhân (kernel) cũ |
| Cơ sở dữ liệu | lưu trữ (archive) hoặc xóa các phân vùng bảng (partition) cũ, chạy `VACUUM` / đóng gói lại bảng (repack), kiểm tra thời gian lưu giữ WAL hoặc binlog và các khe nhân bản (replication slots) bị kẹt |
| Bản sao lưu (Backups) | áp dụng chính sách lưu giữ; chuyển các bộ sao lưu cũ sang lưu trữ đối tượng (object storage) hoặc tầng lưu trữ lạnh (cold tier) |
| Core dumps, heap dumps, tmp | xóa sau khi người phụ trách sự cố xác nhận |
Không bao giờ dùng lệnh `rm` với tệp mà cơ sở dữ liệu đang sử dụng; không bao giờ tự xóa WAL hoặc binlog bằng tay.

## 3. Mở rộng dung lượng (Resize)
- **AWS EBS:** sửa đổi kích thước volume, đợi chuyển sang trạng thái optimizing, sau đó chạy `sudo growpart /dev/nvme0n1 1` và `sudo resize2fs /dev/nvme0n1p1` (đối với ext4) hoặc `sudo xfs_growfs /mount` (đối với xfs). Mỗi volume chỉ được tăng kích thước một lần mỗi 6 giờ.
- **Azure managed disk / GCP persistent disk:** tăng kích thước đĩa (Azure có thể yêu cầu giải phóng VM - deallocate đối với một số loại đĩa), sau đó mở rộng phân vùng và hệ thống tệp theo cách tương tự.
- **LVM:** lệnh `lvextend -r -L +100G vg/lv` sẽ đồng thời mở rộng luôn cả hệ thống tệp.
- **Kubernetes PVC:** chỉnh sửa `spec.resources.requests.storage` khi StorageClass có thiết lập `allowVolumeExpansion: true`.
Các phân vùng có thể mở rộng nhưng hiếm khi thu nhỏ lại được, vì vậy hãy tăng kích thước khớp với mức dự báo chứ đừng tăng vượt quá xa.

## 4. Thiết lập cảnh báo đủ thời gian để xử lý
- Cảnh báo (Warn) ở mức đã dùng 80% hoặc còn 14 ngày dung lượng; gọi trực khẩn cấp (Page) ở mức 90% hoặc còn 3 ngày dung lượng (trên Prometheus dùng các quy tắc dạng `predict_linear(node_filesystem_avail_bytes[6h], 4*3600) < 0`).
- Cảnh báo khi mức sử dụng inode vượt quá 85%.
- Rà soát dung lượng hàng tháng bằng báo cáo của kỹ năng này; cộng thêm mức tăng trưởng dự kiến từ các đợt ra mắt sản phẩm đã lên kế hoạch bằng tay.
FILE:templates/capacity-review.md
# Báo cáo Đánh giá Dung lượng - <dịch vụ / cụm máy chủ> - <ngày>

**Dữ liệu:** <nguồn>, từ <mẫu đầu tiên> đến <mẫu cuối cùng> (<múi giờ>)  **Khung thời gian dự báo:** <90> ngày  **Người đánh giá:** <tên>

## Thời gian cạn kiệt dung lượng (Runway)
| Phân vùng | Trạng thái | Đã dùng | Kích thước | Tốc độ tăng GB/ngày (tổng thể / gần đây) | Chạm 90% vào | Đầy vào | Kích thước đề xuất |
|---|---|---|---|---|---|---|---|
| | | | | | | | |

## Nguyên nhân gây tăng dung lượng
| Phân vùng | Thành phần tiêu thụ chính | Bằng chứng | Đột biến một lần hay diễn ra liên tục |
|---|---|---|---|
| | | | |

## Hành động cần thực hiện
| Phân vùng | Hành động | Người phụ trách | Thời hạn | Dung lượng thu hồi / mở rộng |
|---|---|---|---|---|
| | | | | |

## Cảnh báo
- Cảnh báo (Warning): <80% hoặc còn 14 ngày>  - Gọi trực (Page): <90% hoặc còn 3 ngày>  - Inodes: <85%>

## Đợt rà soát tiếp theo
<ngày>; chạy lại công cụ dự báo với các mẫu dữ liệu mới nhất
FILE:examples/fleet-disk-usage.csv
timestamp,volume,used_gb,size_gb
2026-10-01T06:00,db01:/var/lib/postgresql,300.0,500
2026-10-01T06:00,web01:/var/log,21.0,100
2026-10-01T06:00,ci-runner01:/,110.0,200
2026-10-01T06:00,backup-nas:/srv/backups,2100.0,4000
2026-10-02T06:00,db01:/var/lib/postgresql,303.4,500
2026-10-02T06:00,web01:/var/log,24.4,100
2026-10-02T06:00,ci-runner01:/,111.4,200
2026-10-02T06:00,backup-nas:/srv/backups,2105.4,4000
2026-10-03T06:00,db01:/var/lib/postgresql,305.7,500
2026-10-03T06:00,web01:/var/log,26.7,100
2026-10-03T06:00,ci-runner01:/,111.7,200
2026-10-03T06:00,backup-nas:/srv/backups,2109.7,4000
2026-10-04T06:00,db01:/var/lib/postgresql,309.2,500
2026-10-04T06:00,web01:/var/log,30.2,100
2026-10-04T06:00,ci-runner01:/,113.2,200
2026-10-04T06:00,backup-nas:/srv/backups,2115.2,4000
2026-10-05T06:00,db01:/var/lib/postgresql,311.9,500
2026-10-05T06:00,web01:/var/log,32.9,100
2026-10-05T06:00,ci-runner01:/,113.9,200
2026-10-05T06:00,backup-nas:/srv/backups,2119.9,4000
2026-10-06T06:00,db01:/var/lib/postgresql,315.5,500
2026-10-06T06:00,web01:/var/log,22.5,100
2026-10-06T06:00,ci-runner01:/,115.5,200
2026-10-06T06:00,backup-nas:/srv/backups,2125.5,4000
2026-10-07T06:00,db01:/var/lib/postgresql,317.6,500
2026-10-07T06:00,web01:/var/log,24.6,100
2026-10-07T06:00,ci-runner01:/,115.6,200
2026-10-07T06:00,backup-nas:/srv/backups,2129.6,4000
2026-10-08T06:00,db01:/var/lib/postgresql,321.1,500
2026-10-08T06:00,web01:/var/log,28.1,100
2026-10-08T06:00,ci-runner01:/,117.1,200
2026-10-08T06:00,backup-nas:/srv/backups,2135.1,4000
2026-10-09T06:00,db01:/var/lib/postgresql,324.3,500
2026-10-09T06:00,web01:/var/log,31.3,100
2026-10-09T06:00,ci-runner01:/,118.3,200
2026-10-09T06:00,backup-nas:/srv/backups,2140.3,4000
2026-10-10T06:00,db01:/var/lib/postgresql,326.8,500
2026-10-10T06:00,web01:/var/log,33.8,100
2026-10-10T06:00,ci-runner01:/,118.8,200
2026-10-10T06:00,backup-nas:/srv/backups,2144.8,4000
2026-10-11T06:00,db01:/var/lib/postgresql,330.0,500
2026-10-11T06:00,web01:/var/log,23.0,100
2026-10-11T06:00,ci-runner01:/,120.0,200
2026-10-11T06:00,backup-nas:/srv/backups,2150.0,4000
2026-10-12T06:00,db01:/var/lib/postgresql,333.4,500
2026-10-12T06:00,web01:/var/log,26.4,100
2026-10-12T06:00,ci-runner01:/,121.4,200
2026-10-12T06:00,backup-nas:/srv/backups,2155.4,4000
2026-10-13T06:00,db01:/var/lib/postgresql,335.7,500
2026-10-13T06:00,web01:/var/log,28.7,100
2026-10-13T06:00,ci-runner01:/,121.7,200
2026-10-13T06:00,backup-nas:/srv/backups,2159.7,4000
2026-10-14T06:00,db01:/var/lib/postgresql,339.2,500
2026-10-14T06:00,web01:/var/log,32.2,100
2026-10-14T06:00,ci-runner01:/,123.2,200
2026-10-14T06:00,backup-nas:/srv/backups,2165.2,4000
2026-10-15T06:00,db01:/var/lib/postgresql,341.9,500
2026-10-15T06:00,web01:/var/log,34.9,100
2026-10-15T06:00,ci-runner01:/,123.9,200
2026-10-15T06:00,backup-nas:/srv/backups,2169.9,4000
2026-10-16T06:00,db01:/var/lib/postgresql,345.5,500
2026-10-16T06:00,web01:/var/log,24.5,100
2026-10-16T06:00,ci-runner01:/,130.5,200
2026-10-16T06:00,backup-nas:/srv/backups,2575.5,4000
2026-10-17T06:00,db01:/var/lib/postgresql,347.6,500
2026-10-17T06:00,web01:/var/log,26.6,100
2026-10-17T06:00,ci-runner01:/,135.6,200
2026-10-17T06:00,backup-nas:/srv/backups,2579.6,4000
2026-10-18T06:00,db01:/var/lib/postgresql,351.1,500
2026-10-18T06:00,web01:/var/log,30.1,100
2026-10-18T06:00,ci-runner01:/,142.1,200
2026-10-18T06:00,backup-nas:/srv/backups,2585.1,4000
2026-10-19T06:00,db01:/var/lib/postgresql,354.3,500
2026-10-19T06:00,web01:/var/log,33.3,100
2026-10-19T06:00,ci-runner01:/,148.3,200
2026-10-19T06:00,backup-nas:/srv/backups,2590.3,4000
2026-10-20T06:00,db01:/var/lib/postgresql,356.8,500
2026-10-20T06:00,web01:/var/log,35.8,100
2026-10-20T06:00,ci-runner01:/,153.8,200
2026-10-20T06:00,backup-nas:/srv/backups,2594.8,4000
2026-10-21T06:00,db01:/var/lib/postgresql,360.1,500
2026-10-21T06:00,web01:/var/log,25.1,100
2026-10-21T06:00,ci-runner01:/,160.1,200
2026-10-21T06:00,backup-nas:/srv/backups,2600.1,4000
FILE:scripts/disk_runway.py
#!/usr/bin/env python3
"""Disk capacity runway forecaster (Python 3 standard library only).

Input: CSV with a header row and columns
  timestamp,volume,used_gb,size_gb
timestamp is ISO 8601 ("2026-10-01T00:00", "2026-10-01 06:00" or a date); one row per sample,
any order, several volumes in one file. size_gb may change (a resize); the latest value is used.

Model per volume:
  - growth rate = least-squares slope of used_gb over time (GB/day), plus R^2 for fit quality;
    one-off steps larger than 5% of the size are kept in current usage but removed from the trend
  - recent rate = slope over the last 7 days of samples (or the last third if shorter)
  - runway to 80/90/95/100% full = (threshold x size - used now) / max(rate, recent rate),
    counted from the LAST sample (not from today), so results are reproducible
  - suggested size: smallest whole 10 GB step that keeps the volume under 80% for
    --horizon days (default 90) at the worse of the two rates
Status / findings (severity):
  CRITICAL FULL_SOON       100% within 7 days, or already >= 95% used
  HIGH     RUNWAY_SHORT    90% within 14 days
  MEDIUM   RUNWAY_WATCH    80% within 30 days
  MEDIUM   ACCELERATING    recent rate > 1.5x the overall rate (and > 0.1 GB/day)
  MEDIUM   SUDDEN_JUMP     one step grew by more than 5% of the volume size
  LOW      NOISY_TREND     R^2 < 0.5: usage swings (log rotation, temp files); trust less
  LOW      SHRINKING       usage is falling; no runway
  LOW      FEW_SAMPLES     fewer than 4 samples or under 2 days of data
Usage:
  python3 disk_runway.py usage.csv
  python3 disk_runway.py usage.csv --horizon 180 --json
  python3 disk_runway.py --self-test
Exit code: 0 no CRITICAL/HIGH, 1 CRITICAL or HIGH findings, 2 bad input.
"""
import argparse
import csv
import datetime as dt
import io
import json
import math
import sys

THRESHOLDS = (0.8, 0.9, 0.95, 1.0)


class DataError(ValueError):
    pass


def parse_time(s):
    s = s.strip().replace("Z", "+00:00")
    try:
        t = dt.datetime.fromisoformat(s)
    except ValueError:
        raise DataError(f"bad timestamp {s!r}")
    return t.replace(tzinfo=None) if t.tzinfo is None else t.astimezone(dt.timezone.utc).replace(tzinfo=None)


def read_rows(text):
    rd = csv.DictReader(io.StringIO(text))
    need = {"timestamp", "volume", "used_gb", "size_gb"}
    if not rd.fieldnames or not need <= {f.strip() for f in rd.fieldnames}:
        raise DataError(f"CSV header must contain {', '.join(sorted(need))}")
    vols = {}
    for n, row in enumerate(rd, start=2):
        row = {k.strip(): (v or "").strip() for k, v in row.items() if k}
        if not any(row.values()):
            continue
        try:
            used, size = float(row["used_gb"]), float(row["size_gb"])
        except ValueError:
            raise DataError(f"line {n}: used_gb and size_gb must be numbers")
        if size <= 0 or used < 0:
            raise DataError(f"line {n}: size_gb must be > 0 and used_gb >= 0")
        vols.setdefault(row["volume"] or "(unnamed)", []).append((parse_time(row["timestamp"]), used, size))
    if not vols:
        raise DataError("no data rows")
    return {k: sorted(v) for k, v in vols.items()}


def slope(points):
    """points: [(days, value)] -> (slope per day, r2)"""
    n = len(points)
    if n < 2:
        return 0.0, 0.0
    mx = sum(p[0] for p in points) / n
    my = sum(p[1] for p in points) / n
    sxx = sum((p[0] - mx) ** 2 for p in points)
    if sxx == 0:
        return 0.0, 0.0
    sxy = sum((p[0] - mx) * (p[1] - my) for p in points)
    b = sxy / sxx
    syy = sum((p[1] - my) ** 2 for p in points)
    r2 = 1.0 if syy == 0 else (sxy * sxy) / (sxx * syy)
    return b, r2


def analyze(samples, horizon=90):
    t0 = samples[0][0]
    last_t, used, size = samples[-1]
    # one-off steps (> 5% of size in one sample) are removed from the trend, not extrapolated
    steps = [samples[i][1] - samples[i - 1][1] for i in range(1, len(samples))]
    normal = sorted(x for x in steps if x <= 0.05 * size)
    typical = normal[len(normal) // 2] if normal else 0.0
    shift, pts = 0.0, []
    for i, (t, u, _) in enumerate(samples):
        if i and steps[i - 1] > 0.05 * size:
            shift += steps[i - 1] - typical
        pts.append(((t - t0).total_seconds() / 86400, u - shift))
    span = pts[-1][0]
    rate, r2 = slope(pts)
    cut = pts[-1][0] - 7 if span >= 14 else pts[-1][0] - span / 3
    recent_pts = [p for p in pts if p[0] >= cut]
    recent, _ = slope(recent_pts) if len(recent_pts) >= 2 else (rate, 0)
    worst = max(rate, recent)
    pct = used / size
    runway = {}
    for th in THRESHOLDS:
        left = th * size - used
        if left <= 0:
            runway[th] = (0.0, last_t)
        elif worst > 1e-9:
            d = left / worst
            runway[th] = (d, last_t + dt.timedelta(days=d))
        else:
            runway[th] = (None, None)
    need = used + max(worst, 0) * horizon
    suggest = max(size, math.ceil(need / 0.8 / 10) * 10)
    findings = []

    def f(sev, code, msg):
        findings.append({"severity": sev, "code": code, "message": msg})

    d100, d90, d80 = runway[1.0][0], runway[0.9][0], runway[0.8][0]
    if pct >= 0.95 or (d100 is not None and d100 <= 7):
        f("CRITICAL", "FULL_SOON", f"{pct:.0%} used; full in {fmt_days(d100)}")
    elif d90 is not None and d90 <= 14:
        f("HIGH", "RUNWAY_SHORT", f"90% in {fmt_days(d90)}")
    elif d80 is not None and d80 <= 30:
        f("MEDIUM", "RUNWAY_WATCH", f"80% in {fmt_days(d80)}")
    if rate > 0 and recent > 1.5 * rate and recent > 0.1:
        f("MEDIUM", "ACCELERATING", f"last-week rate {recent:.2f} GB/day vs overall {rate:.2f} GB/day")
    jumps = [(samples[i][0], samples[i][1] - samples[i - 1][1]) for i in range(1, len(samples))]
    big = [j for j in jumps if j[1] > 0.05 * size]
    if big:
        t, g = max(big, key=lambda j: j[1])
        f("MEDIUM", "SUDDEN_JUMP", f"+{g:.1f} GB in one step at {t:%Y-%m-%d %H:%M} (backup, dump, log burst?)")
    if len(pts) >= 4 and r2 < 0.5 and worst > 0:
        f("LOW", "NOISY_TREND", f"R^2 {r2:.2f}; usage swings, so treat the runway as rough")
    if worst < 0:
        f("LOW", "SHRINKING", f"usage falling {rate:.2f} GB/day")
    if len(pts) < 4 or span < 2:
        f("LOW", "FEW_SAMPLES", f"{len(pts)} samples over {span:.1f} days; collect more data")
    rank = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
    findings.sort(key=lambda x: rank[x["severity"]])
    return {"last_sample": last_t.strftime("%Y-%m-%d %H:%M"), "used_gb": round(used, 2), "size_gb": size,
            "used_pct": round(pct * 100, 1), "rate_gb_day": round(rate, 3), "recent_rate_gb_day": round(recent, 3),
            "r2": round(r2, 3), "samples": len(pts), "span_days": round(span, 2),
            "runway": {f"{int(th * 100)}%": {"days": None if d is None else round(d, 1),
                                             "date": None if t is None else t.strftime("%Y-%m-%d")}
                       for th, (d, t) in runway.items()},
            "suggested_size_gb": suggest, "horizon_days": horizon, "findings": findings,
            "status": findings[0]["severity"] if findings and findings[0]["severity"] in ("CRITICAL", "HIGH", "MEDIUM") else "OK"}


def fmt_days(d):
    if d is None:
        return "never at current rate"
    if d <= 0:
        return "already"
    if d < 1:
        return f"{d * 24:.0f} h"
    return f"{d:.1f} days"


def report(res, horizon):
    out = ["# Disk runway report", "", f"Runway uses the worse of the overall and last-week growth rate; suggested size keeps usage under 80% for {horizon} days.", "",
           "| Volume | Status | Used | Size | Growth GB/day (overall / recent) | 80% | 90% | 100% | Suggested size |",
           "|---|---|---|---|---|---|---|---|---|"]
    order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "OK": 3}
    for name, r in sorted(res.items(), key=lambda kv: (order[kv[1]["status"]], kv[0])):
        def cell(k):
            x = r["runway"][k]
            if x["days"] is None:
                return "never"
            return "now" if x["days"] == 0 else f"{x['days']} d ({x['date']})"
        out.append(f"| {name} | {r['status']} | {r['used_gb']:g} GB ({r['used_pct']}%) | {r['size_gb']:g} GB | "
                   f"{r['rate_gb_day']} / {r['recent_rate_gb_day']} | {cell('80%')} | {cell('90%')} | {cell('100%')} | "
                   f"{r['suggested_size_gb']:g} GB |")
    out += ["", "## Findings"]
    any_f = False
    for name, r in sorted(res.items(), key=lambda kv: (order[kv[1]["status"]], kv[0])):
        for x in r["findings"]:
            any_f = True
            out.append(f"- **{x['severity']} {x['code']}** ({name}): {x['message']}")
    if not any_f:
        out.append("- none")
    out += ["", "Last samples: " + ", ".join(f"{n} {r['last_sample']} ({r['samples']} samples, {r['span_days']} days, R^2 {r['r2']})" for n, r in sorted(res.items()))]
    return "\n".join(out) + "\n"


def run(text, horizon=90):
    if horizon <= 0:
        raise DataError("horizon must be > 0")
    return {name: analyze(s, horizon) for name, s in read_rows(text).items()}


def self_test():
    cases = []

    def ok(name, cond):
        cases.append(bool(cond))
        print(("PASS " if cond else "FAIL ") + name)

    b, r2 = slope([(0, 10), (1, 12), (2, 14)])
    ok("slope 2/day exact fit", abs(b - 2) < 1e-9 and abs(r2 - 1) < 1e-9)
    ok("flat slope 0", slope([(0, 5), (1, 5), (2, 5)])[0] == 0)
    hdr = "timestamp,volume,used_gb,size_gb\n"
    lin = hdr + "".join(f"2026-10-{d:02d},v,{100 + 2 * d},500\n" for d in range(1, 15))
    r = run(lin)["v"]
    ok("rate 2 GB/day", abs(r["rate_gb_day"] - 2) < 1e-6)
    ok("80% runway (400-128)/2 = 136 d", r["runway"]["80%"]["days"] == 136.0)
    ok("runway date from last sample", r["runway"]["100%"]["date"] == "2027-04-18")
    ok("healthy volume status OK", r["status"] == "OK" and r["findings"] == [])
    ok("suggested size keeps 500", r["suggested_size_gb"] == 500)
    r = run(lin, horizon=180)["v"]
    ok("180 d horizon suggests 610", r["suggested_size_gb"] == 610)
    full = hdr + "".join(f"2026-10-{d:02d},v,{80 + 3 * d},100\n" for d in range(1, 6))
    r = run(full)["v"]
    ok("95% used is CRITICAL", r["status"] == "CRITICAL" and r["findings"][0]["code"] == "FULL_SOON")
    ok("past threshold runway is 0", r["runway"]["90%"]["days"] == 0)
    short = hdr + "".join(f"2026-10-{d:02d},v,{50 + 3 * d},100\n" for d in range(1, 8))
    r = run(short)["v"]
    ok("90% in ~6 days is HIGH", r["status"] == "HIGH")
    watch = hdr + "".join(f"2026-10-{d:02d},v,{40 + d},100\n" for d in range(1, 8))
    ok("80% in 33 d is OK, 80% in 26 d is MEDIUM", run(watch)["v"]["status"] == "OK" and
       run(hdr + "".join(f"2026-10-{d:02d},v,{47 + d},100\n" for d in range(1, 8)))["v"]["status"] == "MEDIUM")
    acc = hdr + "".join(f"2026-10-{d:02d},v,{100 + (d if d <= 14 else 14 + 5 * (d - 14))},1000\n" for d in range(1, 22))
    r = run(acc)["v"]
    ok("accelerating flagged", any(x["code"] == "ACCELERATING" for x in r["findings"]))
    ok("recent rate used for runway", r["recent_rate_gb_day"] > r["rate_gb_day"])
    jump = hdr + "2026-10-01,v,100,500\n2026-10-02,v,101,500\n2026-10-03,v,140,500\n2026-10-04,v,141,500\n"
    r = run(jump)["v"]
    ok("sudden jump flagged", any(x["code"] == "SUDDEN_JUMP" for x in r["findings"]))
    ok("jump removed from trend (1 GB/day)", abs(r["rate_gb_day"] - 1) < 1e-6 and r["used_gb"] == 141)
    noisy = hdr + "".join(f"2026-10-{d:02d},v,{100 + (8 if d % 2 else 0) + 0.3 * d:.1f},500\n" for d in range(1, 21))
    ok("noisy trend flagged", any(x["code"] == "NOISY_TREND" for x in run(noisy)["v"]["findings"]))
    shr = hdr + "".join(f"2026-10-{d:02d},v,{200 - 3 * d},500\n" for d in range(1, 8))
    r = run(shr)["v"]
    ok("shrinking flagged and runway never", any(x["code"] == "SHRINKING" for x in r["findings"]) and r["runway"]["100%"]["days"] is None)
    few = hdr + "2026-10-01T00:00,v,10,100\n2026-10-01T12:00,v,11,100\n"
    ok("few samples flagged", any(x["code"] == "FEW_SAMPLES" for x in run(few)["v"]["findings"]))
    multi = hdr + "2026-10-02,b,5,100\n2026-10-01,a,1,100\n2026-10-01,b,4,100\n2026-10-02,a,1,100\n"
    r = run(multi)
    ok("multiple volumes, unsorted rows", set(r) == {"a", "b"} and r["b"]["rate_gb_day"] == 1)
    ok("resize uses latest size", run(hdr + "2026-10-01,v,50,100\n2026-10-02,v,52,200\n")["v"]["size_gb"] == 200)
    ok("fmt_days hours", fmt_days(0.5) == "12 h")
    for bad, label in (("a,b\n1,2\n", "missing columns"), (hdr, "no rows"), (hdr + "x,v,1,10\n", "bad timestamp"),
                       (hdr + "2026-10-01,v,abc,10\n", "bad number"), (hdr + "2026-10-01,v,1,0\n", "zero size")):
        try:
            run(bad)
            ok(label + " rejected", False)
        except DataError:
            ok(label + " rejected", True)
    print(f"{sum(cases)}/{len(cases)} passed")
    return 0 if all(cases) else 1


def main(argv=None):
    ap = argparse.ArgumentParser(description="Forecast when disks fill up from usage samples.")
    ap.add_argument("file", nargs="?", help="CSV: timestamp,volume,used_gb,size_gb")
    ap.add_argument("--horizon", type=int, default=90, help="days of runway the suggested size must cover (default 90)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args(argv)
    if a.self_test:
        return self_test()
    if not a.file:
        ap.error("give a CSV file or --self-test")
    try:
        res = run(open(a.file, encoding="utf-8").read(), a.horizon)
    except (OSError, DataError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(res, indent=2) if a.json else report(res, a.horizon), end="\n" if a.json else "")
    return 1 if any(r["status"] in ("CRITICAL", "HIGH") for r in res.values()) else 0


if __name__ == "__main__":
    sys.exit(main())
DevOpsData AnalysisLinux+2
F@f
0
Trình lập kế hoạch chính sách lưu giữ bản sao lưu
Kỹ năng

Lập kế hoạch và kiểm toán các chính sách lưu giữ bản sao lưu (ông-cha-con / grandfather-father-son). Mô phỏng các quy tắc giữ lại theo giờ, ngày, tuần, tháng và năm giống như restic hoặc borg, liệt kê các điểm khôi phục thực sự được giữ lại và khoảng trống giữa chúng, đồng thời ước tính dung lượng lưu trữ và chi phí hiện tại cũng như trong 12 tháng tới. Đánh dấu RPO bị bỏ lỡ, lịch sử ngắn, thiếu hụt lưu giữ pháp lý, không có bản sao ngoài cơ sở (offsite) hoặc bất biến (immutable), các bài kiểm tra khôi phục đã cũ và sự chồng chéo tác vụ. Bao gồm một trình lập kế hoạch Python dùng thư viện chuẩn với tùy chọn --self-test.

---
name: backup-retention-policy-planner
description: Lập kế hoạch và kiểm toán các chính sách lưu giữ bản sao lưu (ông-cha-con / grandfather-father-son). Mô phỏng các quy tắc giữ lại theo giờ, ngày, tuần, tháng và năm giống như restic hoặc borg, liệt kê các điểm khôi phục thực sự được giữ lại và khoảng trống giữa chúng, đồng thời ước tính dung lượng lưu trữ và chi phí hiện tại cũng như trong 12 tháng tới. Đánh dấu RPO bị bỏ lỡ, lịch sử ngắn, thiếu hụt lưu giữ pháp lý, không có bản sao ngoài cơ sở (offsite) hoặc bất biến (immutable), các bài kiểm tra khôi phục đã cũ và sự chồng chéo tác vụ. Bao gồm một trình lập kế hoạch Python dùng thư viện chuẩn với tùy chọn --self-test.
---

# Trình lập kế hoạch chính sách lưu giữ bản sao lưu

Bạn giúp các kỹ sư và các nhóm CNTT nhỏ thiết kế một chính sách lưu giữ bản sao lưu mà họ có thể bảo vệ được: đủ điểm khôi phục để phục hồi từ sai lầm ngày hôm qua và lỗi hỏng dữ liệu âm thầm từ quý trước, một bản sao sống sót qua cuộc tấn công ransomware hoặc mất tài khoản, hóa đơn lưu trữ vừa với ngân sách, và quy trình khôi phục thực sự đã được kiểm thử. Bạn biến quy tắc "giữ 7 bản hàng ngày, 4 bản hàng tuần, 12 bản hàng tháng" thành danh sách cụ thể các điểm khôi phục mà nó tạo ra và đối chiếu với các yêu cầu.

## Các tệp trong kỹ năng này

- `scripts/retention_plan.py` - mô phỏng chính sách GFS, liệt kê các điểm khôi phục được giữ lại, các khoảng trống, dung lượng lưu trữ và chi phí, đồng thời in ra các phát hiện (chỉ dùng thư viện chuẩn Python 3)
- `references/retention-design.md` - cách các quy tắc giữ lại chọn điểm khôi phục, cách chọn các bậc (tier) từ nhu cầu RPO và lịch sử, lưu giữ pháp lý, các mô hình lưu trữ và chi phí
- `references/resilience-checklist.md` - quy tắc 3-2-1-1-0, tính bất biến (immutability), mã hóa và khóa, kiểm thử khôi phục, giám sát và ý nghĩa của từng phát hiện
- `templates/backup-policy.md` - mẫu chính sách sao lưu dài một trang để điền vào và chia sẻ với nhóm
- `examples/clinic-postgres-policy.json` - cơ sở dữ liệu PostgreSQL được sao lưu hàng đêm lên S3 với RPO 4 giờ và thời gian lưu giữ pháp lý 3 năm

## Quy trình làm việc

### 1. Thu thập thông tin thực tế
Hỏi về: những gì được sao lưu và bằng cách nào (công cụ, sao lưu toàn phần hay tăng dần), tần suất sao lưu và thời gian chạy một lần mất bao lâu, các quy tắc giữ lại hiện tại, kích thước dữ liệu, tỷ lệ thay đổi hàng ngày và mức tăng trưởng, nơi lưu trữ các bản sao (tại chỗ, ngoài cơ sở, bất biến/khóa đối tượng, được mã hóa), giá lưu trữ, ngày kiểm thử khôi phục thành công gần nhất, và các yêu cầu: RPO (mức mất dữ liệu tối đa), họ phải có khả năng khôi phục lùi về quá khứ bao xa, bất kỳ yêu cầu lưu giữ pháp lý hoặc theo hợp đồng nào, và ngân sách lưu trữ. Nếu họ không biết tỷ lệ thay đổi, hãy bắt đầu với mức 2-5% mỗi ngày đối với cơ sở dữ liệu và 1% đối với chia sẻ tệp, đồng thời nêu rõ giả định đó.

### 2. Chạy trình lập kế hoạch
```
python3 scripts/retention_plan.py examples/clinic-postgres-policy.json
python3 scripts/retention_plan.py policy.json --json
python3 scripts/retention_plan.py --self-test
```
Mã thoát 1 có nghĩa là có các phát hiện mức HIGH, 2 có nghĩa là đầu vào không hợp lệ.

### 3. Giải thích kết quả
Bắt đầu với các điểm khôi phục mà họ thực sự có (số lượng, bản cũ nhất, các khoảng trống lớn nhất) vì hầu hết mọi người đều đánh giá quá cao chúng. Sau đó đi qua các phát hiện theo mức độ nghiêm trọng bằng cách sử dụng `references/retention-design.md` và `references/resilience-checklist.md`, đồng thời đưa ra một thay đổi cụ thể cho từng vấn đề: quy tắc giữ lại mới, vận chuyển WAL hoặc nhật ký (log shipping) cho RPO chặt chẽ, khóa đối tượng (object lock), ngày diễn tập khôi phục. Chạy lại trình lập kế hoạch với chính sách được đề xuất và hiển thị dung lượng lưu trữ cũng như chi phí trước và sau khi thay đổi.

### 4. Bàn giao
Điền vào `templates/backup-policy.md` với các quy tắc giữ lại cuối cùng, các bản sao, yêu cầu, kế hoạch kiểm thử khôi phục và người phụ trách.

## Kết quả ví dụ
Đối với `examples/clinic-postgres-policy.json` (sao lưu hàng đêm, giữ 7 bản hàng ngày, 4 bản hàng tuần, 6 bản hàng tháng): 14 điểm khôi phục, bản cũ nhất 4,8 tháng, khoảng trống lên tới 31 ngày sau tháng đầu tiên, 968 GB hiện tại và 1550 GB sau 12 tháng (22,27 đến 35,66 USD/tháng). Các phát hiện: HIGH RPO_MISSED (sao lưu 24 giờ một lần so với RPO 4 giờ; cần thêm lưu trữ WAL hoặc sao lưu tăng dần mỗi 4 giờ), HIGH LEGAL_RETENTION (cần 3 năm; hãy đặt bậc hàng năm thành 5), MEDIUM RESTORE_TEST_OLD (193 ngày), MEDIUM NO_IMMUTABLE, MEDIUM STORAGE_OVER (1550 GB so với ngân sách 1500 GB sau 12 tháng).

## Quy tắc

- Một bản sao lưu chưa bao giờ được khôi phục thử thì chưa được chứng minh là hoạt động. Luôn hỏi về lần kiểm thử khôi phục gần nhất và khuyến nghị một buổi diễn tập có bấm giờ.
- Các quy tắc giữ lại đếm theo các khung thời gian (bucket), không phải số ngày: "monthly 12" giữ lại bản sao lưu mới nhất của mỗi tháng trong 12 tháng gần nhất có bản sao lưu, bao gồm cả tháng hiện tại.
- Nêu rõ mô hình lưu trữ mà bạn đã giả định; ước tính sao lưu tăng dần (incremental) là mức trần vì việc chống trùng lặp (deduplication) và các thay đổi khối lặp đi lặp lại làm cho kích thước thực tế nhỏ hơn.
- RPO được quyết định bởi tần suất sao lưu (hoặc log shipping), không phải bởi thời gian lưu giữ; độ sâu lịch sử được quyết định bởi thời gian lưu giữ, không phải bởi tần suất.
- Không bao giờ đề xuất xóa các bản sao lưu hiện có cho đến khi chính sách mới đã chạy và việc khôi phục từ chính sách đó đã được kiểm thử.
FILE:references/retention-design.md
# Thiết kế chính sách lưu giữ

## Cách các quy tắc giữ lại chọn điểm khôi phục
Các công cụ như restic (`forget --keep-daily 7 --keep-weekly 4`), borg (`prune --keep-daily 7`), pgBackRest và hầu hết các thiết bị sao lưu chuyên dụng đều sử dụng chung một ý tưởng. Đối với mỗi bậc (tier), duyệt qua các bản sao lưu từ mới nhất đến cũ nhất và giữ lại một bản sao lưu khi khung thời gian (bucket) của nó khác với bản cuối cùng được giữ lại bởi bậc đó, cho đến khi dùng hết số lượng của bậc đó:

| Bậc | Khung thời gian (Bucket) | "giữ N" có nghĩa là |
|---|---|---|
| hourly | giờ theo lịch | bản sao lưu mới nhất của mỗi giờ trong N giờ gần nhất có bản sao lưu |
| daily | ngày theo lịch | bản sao lưu mới nhất của mỗi ngày trong N ngày gần nhất |
| weekly | tuần ISO (Thứ Hai đến Chủ Nhật) | bản sao lưu mới nhất của mỗi tuần trong N tuần gần nhất |
| monthly | tháng theo lịch | bản sao lưu mới nhất của mỗi tháng trong N tháng gần nhất |
| yearly | năm theo lịch | bản sao lưu mới nhất của mỗi năm trong N năm gần nhất |

Những hệ quả mà mọi người thường bỏ sót:
- Mọi bậc đều bắt đầu bằng bản sao lưu mới nhất, vì vậy các bậc có sự chồng lấn. 7 daily + 4 weekly + 12 monthly là 20 điểm, không phải 23.
- Tuần, tháng và năm hiện tại mỗi thứ được tính là một khung thời gian. "monthly 12" chỉ lùi về trước khoảng 11 tháng, và "yearly 3" chỉ lùi về đến cuối năm kia (khoảng 1,8 năm nếu tính vào cuối tháng Mười).
- Các điểm hàng tháng là các bản sao lưu cuối tháng (mới nhất trong mỗi tháng), các điểm hàng tuần là các bản sao lưu ngày Chủ Nhật đối với tuần ISO.
- Những ngày không có bản sao lưu không làm tiêu tốn một vị trí (slot) hàng ngày, vì vậy một tuần bị lỗi sẽ kéo dài lịch sử hàng ngày lùi xa hơn về trước nhưng lại để lại một lỗ hổng.

`scripts/retention_plan.py` tái tạo quy tắc này, in ra mọi điểm được giữ lại cùng với các bậc của nó (D/W/M/Y/H) và liệt kê các khoảng trống lớn nhất.

## Chọn các bậc từ yêu cầu
| Yêu cầu | Yếu tố quyết định | Câu trả lời điển hình |
|---|---|---|
| RPO (mức mất dữ liệu tối đa) | tần suất sao lưu hoặc log shipping | 24 giờ: hàng đêm; 1-4 giờ: snapshot hoặc sao lưu tăng dần hàng giờ; tính bằng phút: lưu trữ WAL/binlog của cơ sở dữ liệu với khả năng khôi phục tại một thời điểm (point-in-time recovery) |
| Phục hồi từ sai lầm được phát hiện muộn | độ sâu hàng ngày và hàng tuần | ít nhất 14-35 ngày: khoảng thời gian cần thiết để ai đó nhận ra một đợt triển khai lỗi, một thư mục bị xóa hoặc một lần nhập dữ liệu hỏng |
| Lỗi hỏng dữ liệu âm thầm, kiểm toán | độ sâu hàng tháng | 6-13 tháng |
| Lưu giữ pháp lý hoặc theo hợp đồng | bậc hàng năm hoặc một kho lưu trữ riêng | N năm cần mức yearly N+2 để luôn có một điểm cũ N năm xuyên suốt cả năm; thường tốt hơn nếu tách thành một tác vụ lưu trữ riêng biệt với khóa bảo vệ riêng |
| Ngân sách | mô hình lưu trữ và tỷ lệ thay đổi | xem bên dưới |

Một điểm khởi đầu phổ biến, dễ bảo vệ cho dữ liệu doanh nghiệp: hourly 24 (nếu RPO < 24 giờ), daily 14, weekly 8, monthly 12, yearly theo yêu cầu của pháp luật.

Các khoảng trống: khi một bậc thô bắt đầu rất lâu sau khi bậc chi tiết kết thúc (ví dụ daily 7 rồi nhảy sang monthly), việc khôi phục trong khoảng từ ngày thứ 8 đến cuối tháng sẽ rơi vào một điểm cũ hơn tới 30 ngày so với điểm bạn mong muốn. Hãy thêm các điểm hàng tuần hoặc tăng thêm các điểm hàng ngày (`TIER_GAP`).

## Các mô hình lưu trữ
- **Full (Toàn phần)**: mọi điểm được giữ lại đều là một bản sao hoàn chỉnh (băng từ, các tệp dump đơn giản, một số bản xuất snapshot). Dung lượng lưu trữ = số điểm x kích thước toàn phần.
- **Incremental / deduplicated (Tăng dần / chống trùng lặp)** (restic, borg, pgBackRest incremental, ZFS/EBS snapshot): một bản toàn phần cộng với dữ liệu đã thay đổi giữa các điểm được giữ lại. Trình lập kế hoạch tính `daily_change_pct x days` giữa các điểm liền kề, giới hạn tối đa ở mức 100% kích thước toàn phần. Đây là mức trần: khi cùng các khối dữ liệu đó thay đổi mỗi ngày, khoảng trống một tháng sẽ tốn ít dung lượng hơn nhiều so với 30 ngày thay đổi cộng dồn. Hãy đo các con số thực tế bằng `restic stats --mode raw-data`, `borg info` hoặc hóa đơn lưu trữ và điều chỉnh `daily_change_pct`.
- Mức tăng trưởng: `growth_pct_per_month` tính lãi kép trong 12 tháng để hiển thị dung lượng lưu trữ và chi phí của năm tới.

Các đòn bẩy chi phí, từ rẻ nhất đến có tác động lớn nhất tới khả năng phục hồi: nén và chống trùng lặp; chuyển các điểm hàng tháng và hàng năm sang lớp lưu trữ lạnh hơn (lưu ý thời gian lưu trữ tối thiểu và phí truy xuất); bỏ các điểm hàng giờ sau 2-3 ngày; giảm các điểm hàng tháng sau khi nhu cầu pháp lý đã được đáp ứng bởi một bản lưu trữ hàng năm.

## Ví dụ minh họa
Sao lưu hàng đêm lúc 02:00, giữ daily 7, weekly 4, monthly 6, tính đến ngày 2026-10-24:
- daily: 10-24 lùi về 10-18
- weekly: 10-24 (tuần ISO hiện tại), 10-18, 10-11, 10-04
- monthly: 10-24, 09-30, 08-31, 07-31, 06-30, 05-31
- 14 điểm riêng biệt; bản cũ nhất là 2026-05-31 (khoảng 4,8 tháng); các khoảng trống sau tháng đầu tiên là 30-31 ngày.
FILE:references/resilience-checklist.md
# Danh sách kiểm tra khả năng chống chịu

## Quy tắc 3-2-1-1-0
- **3** bản sao dữ liệu (bản production cộng với hai bản sao lưu)
- **2** loại hoặc hệ thống lưu trữ khác nhau
- **1** bản sao ngoài cơ sở - offsite (một vùng, nhà cung cấp hoặc tòa nhà khác)
- **1** bản sao bất biến hoặc ngoại tuyến (khóa đối tượng ở chế độ tuân thủ, lưu trữ WORM, băng từ lấy ra khỏi thư viện, bản sao lưu dạng kéo mà hệ thống nguồn không thể xóa)
- **0** lỗi trong lần khôi phục đã xác minh gần nhất

## Ý nghĩa của từng phát hiện và cách khắc phục
| Mã | Mức độ nghiêm trọng | Tại sao điều này quan trọng | Cách khắc phục điển hình |
|---|---|---|---|
| RPO_MISSED | HIGH | Sự cố xảy ra ngay trước lần sao lưu tiếp theo làm mất nhiều dữ liệu hơn mức doanh nghiệp chấp nhận | Khoảng thời gian ngắn hơn, sao lưu tăng dần hàng giờ, hoặc vận chuyển nhật ký liên tục (lưu trữ PostgreSQL WAL với pgBackRest/WAL-G, MySQL binlog, sao lưu nhật ký SQL Server) |
| HISTORY_SHORT | HIGH | Các vấn đề được phát hiện muộn (nhập dữ liệu lỗi, thư mục bị xóa, hỏng dữ liệu chậm) không thể hoàn tác | Thêm nhiều điểm hàng ngày/hàng tuần hơn; ít nhất bằng thời gian cần thiết để phát hiện sự cố cộng với một khoảng dự phòng |
| LEGAL_RETENTION | HIGH | Các quy định hợp đồng, thuế hoặc hồ sơ y tế có thể yêu cầu lưu giữ lịch sử nhiều năm | Bậc hàng năm ở mức N+2 hoặc một tác vụ lưu trữ hàng năm riêng biệt có khóa đối tượng được đặt theo thời hạn pháp lý |
| NO_OFFSITE | HIGH | Hỏa hoạn, mất điện toàn vùng hoặc tài khoản bị xâm phạm sẽ xóa sổ cả môi trường production lẫn bản sao lưu cùng lúc | Nhân bản sang vùng/nhà cung cấp khác với thông tin xác thực riêng biệt |
| NEVER_RESTORED | HIGH | Các bản sao lưu thất bại âm thầm: thiếu tệp, sai quyền truy cập, mất khóa mã hóa, sổ tay vận hành (runbook) chưa được kiểm thử | Diễn tập khôi phục ngay; ghi lại thời gian thực hiện (RTO thực tế) |
| OVERLAP | HIGH | Các tác vụ va chạm hoặc bị bỏ qua, khiến khoảng cách thực tế dài hơn kế hoạch | Sao lưu tăng dần, thiết bị lưu trữ nhanh hơn, hoặc khoảng cách dài hơn kết hợp với vận chuyển nhật ký (log shipping) |
| RESTORE_TEST_OLD | MEDIUM | Hệ thống thay đổi liên tục; bài kiểm tra từ mùa xuân năm ngoái không nói lên được nhiều điều về hiện tại | Diễn tập hàng quý; tự động khôi phục và kiểm tra checksum hàng tháng đối với dữ liệu quan trọng |
| NO_IMMUTABLE | MEDIUM | Ransomware và kẻ tấn công luôn xóa các bản sao lưu trước tiên | S3 Object Lock / Azure immutable blob / GCS bucket lock, hoặc một bản sao ngoại tuyến |
| NOT_ENCRYPTED | MEDIUM | Bản sao ngoài cơ sở sẽ làm lộ toàn bộ dữ liệu nếu bucket hoặc băng từ bị phơi bày | Mã hóa phía máy khách (client-side); khóa được lưu trữ bên ngoài hệ thống sao lưu và được ký quỹ (escrow) an toàn |
| STORAGE_OVER | MEDIUM | Ngân sách buộc ai đó phải cắt giảm thời gian lưu giữ một cách vội vàng về sau | Lưu trữ lạnh hơn cho các bậc cũ, giảm bớt các điểm hàng tháng, đo tỷ lệ thay đổi thực tế |
| TIER_GAP | LOW | Việc khôi phục rơi vào khoảng trống sẽ phải dùng điểm cũ hơn nhiều so với mong muốn | Thêm các điểm hàng tuần hoặc kéo dài các điểm hàng ngày |
| TIGHT_WINDOW | LOW | Sự gia tăng dữ liệu sẽ đẩy tác vụ vào tình trạng chồng chéo | Lên kế hoạch sao lưu tăng dần hoặc song song trước khi điều đó xảy ra |

## Diễn tập khôi phục (một trang)
1. Chọn một điểm khôi phục ngẫu nhiên, không phải điểm mới nhất.
2. Khôi phục vào một môi trường cô lập chỉ sử dụng sổ tay vận hành (runbook) và các khóa được ký quỹ.
3. Kiểm tra: số lượng hàng hoặc số lượng tệp, ứng dụng khởi động được, một bản ghi đã biết từ ngày đó có tồn tại.
4. Ghi lại thời gian bắt đầu và kết thúc (RTO thực tế), các vấn đề gặp phải và những điểm cần sửa trong sổ tay vận hành.
5. Xóa bản sao đã khôi phục và ghi nhận ngày diễn tập; trường `last_restore_test` của trình lập kế hoạch chính là ngày này.

## Giám sát
- Cảnh báo khi bản sao lưu mới nhất cũ hơn 1,5 lần khoảng cách định kỳ.
- Cảnh báo khi có các tác vụ thất bại hoặc chỉ hoàn thành một phần, chứ không chỉ khi thiếu bản sao lưu.
- Theo dõi kích thước bản sao lưu mỗi lần chạy; sự sụt giảm đột ngột thường có nghĩa là đường dẫn bao gồm (include path) bị hỏng, sự tăng vọt đột ngột có thể là dấu hiệu bị ransomware mã hóa.
- Kiểm tra xem các tác vụ prune/forget có chạy hay không; một chính sách không bao giờ được thực thi sẽ âm thầm làm đầy bộ nhớ.

## Khóa và quyền truy cập
- Thông tin xác thực sao lưu nên chỉ có quyền ghi (write-only) từ phía nguồn ở những nơi có thể; việc xóa cần một định danh khác.
- Giữ các khóa mã hóa và sổ tay vận hành khôi phục ở nơi vẫn tồn tại được ngay cả khi mất môi trường chính (kho lưu trữ của trình quản lý mật khẩu, bản in niêm phong).
FILE:templates/backup-policy.md
# Chính sách Sao lưu - <system name>

**Chủ sở hữu:** <team / person>  **Đã xem xét:** <date>  **Lần xem xét tiếp theo:** <date + 6 months>

## Phạm vi
- Dữ liệu: <databases, buckets, file shares, configs>
- Loại trừ: <caches, rebuildable artifacts>

## Yêu cầu
| Hạng mục | Mục tiêu | Nguồn yêu cầu |
|---|---|---|
| RPO (mức mất dữ liệu tối đa) | <4 h> | <business owner> |
| RTO (thời gian khôi phục tối đa) | <2 h> | <business owner> |
| Lịch sử tối thiểu | <35 days> | <ops> |
| Lưu giữ pháp lý / theo hợp đồng | <3 years> | <contract, regulation> |
| Ngân sách lưu trữ | <1500 GB / 40 USD per month> | <finance> |

## Lịch trình và quy tắc giữ lại
| Tác vụ | Tần suất | Phương thức | Quy tắc giữ lại |
|---|---|---|---|
| <full/incremental> | <daily 02:00> | <pgBackRest incr to S3> | <daily 14, weekly 8, monthly 12, yearly 5> |
| <log shipping> | <continuous> | <WAL archive> | <14 days> |

Các điểm khôi phục được tạo ra (từ `scripts/retention_plan.py`): <count>, bản cũ nhất <age>, khoảng trống lớn nhất <days>.

## Các bản sao (3-2-1-1-0)
| Bản sao | Vị trí | Bất biến | Mã hóa | Thông tin xác thực |
|---|---|---|---|---|
| Bản sao lưu chính | <region A bucket> | <no> | <yes> | <backup-writer role> |
| Ngoài cơ sở (Offsite) | <region B / provider B> | <object lock 35 d> | <yes> | <separate account> |

## Dung lượng lưu trữ và chi phí
- Hiện tại: <GB> / <cost per month>  - Sau 12 tháng: <GB> / <cost per month>

## Kiểm thử khôi phục
- Tần suất: <quarterly drill, monthly automated check>
- Lần kiểm thử gần nhất: <date>, kết quả <ok / issues>, RTO thực tế <time>
- Sổ tay vận hành (Runbook): <link>  - Khóa được ký quỹ tại: <location>

## Giám sát và cảnh báo
- <newest backup older than 36 h -> page on-call>
- <job failed / size change > 30% -> ticket>

## Các phát hiện đang mở
- <severity code: action, owner, due date>
FILE:examples/clinic-postgres-policy.json
{
  "name": "clinic-booking PostgreSQL (pgBackRest to S3)",
  "today": "2026-10-24",
  "backup_interval_hours": 24,
  "backup_duration_hours": 2.5,
  "policy": {"daily": 7, "weekly": 4, "monthly": 6, "yearly": 0},
  "size": {"full_gb": 180, "daily_change_pct": 3, "growth_pct_per_month": 4, "storage_model": "incremental"},
  "requirements": {
    "rpo_hours": 4,
    "min_history_days": 90,
    "legal_retention_years": 3,
    "max_storage_gb": 1500,
    "restore_test_max_age_days": 90
  },
  "copies": {"onsite": false, "offsite": true, "immutable": false, "encrypted": true},
  "price_per_gb_month": 0.023,
  "currency": "USD",
  "last_restore_test": "2026-04-14"
}
FILE:scripts/retention_plan.py
#!/usr/bin/env python3
"""Backup retention policy planner (Python 3 standard library only).

Simulates a grandfather-father-son (GFS) retention policy the way restic/borg "keep" rules
work, then reports the restore points you really keep, the gaps between them, storage and
cost, and the gaps against your requirements (RPO, minimum history, legal retention, 3-2-1).

Input JSON:
  {"name": "orders-db", "today": "2026-10-24",
   "backup_interval_hours": 24, "backup_duration_hours": 1.5,
   "policy": {"hourly": 0, "daily": 7, "weekly": 4, "monthly": 12, "yearly": 3},
   "size": {"full_gb": 120, "daily_change_pct": 4, "growth_pct_per_month": 3,
            "storage_model": "incremental"},          # or "full" (every point is a full copy)
   "requirements": {"rpo_hours": 24, "min_history_days": 35, "legal_retention_years": 0,
                    "max_storage_gb": 0, "restore_test_max_age_days": 90},
   "copies": {"onsite": true, "offsite": true, "immutable": false, "encrypted": true},
   "price_per_gb_month": 0.023, "currency": "USD",
   "last_restore_test": "2026-03-02"}                 # or null if never tested
Selection rule per tier (newest backup first): keep a backup if its bucket (hour, day, ISO week,
month, year) differs from the previous kept bucket of that tier, until the tier count is used.
A backup kept by several tiers is stored once.
Storage: "full" = points x full size; "incremental" = one full + each later point's changed data
(daily_change_pct x days since the previous kept point, capped at 100%).
Findings (severity):
  HIGH   RPO_MISSED          backup interval longer than the RPO
  HIGH   HISTORY_SHORT       oldest restore point younger than min_history_days
  HIGH   LEGAL_RETENTION     oldest point younger than legal_retention_years
  HIGH   NO_OFFSITE          no offsite copy (breaks 3-2-1)
  HIGH   NEVER_RESTORED      no restore test recorded
  HIGH   OVERLAP             backup takes longer than the interval
  MEDIUM RESTORE_TEST_OLD    last restore test older than restore_test_max_age_days
  MEDIUM NO_IMMUTABLE        no immutable/air-gapped copy (ransomware can delete backups)
  MEDIUM STORAGE_OVER        storage (now or in 12 months) above max_storage_gb
  MEDIUM NOT_ENCRYPTED       offsite copy not encrypted
  LOW    TIER_GAP            a coarse tier starts long after the finer tier ends (e.g. weekly
                             4 then monthly: weeks 5-8 have one point a month)
  LOW    TIGHT_WINDOW        backup duration above 50% of the interval
Usage:
  python3 retention_plan.py policy.json
  python3 retention_plan.py policy.json --json
  python3 retention_plan.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import datetime as dt
import json
import sys

TIERS = ["hourly", "daily", "weekly", "monthly", "yearly"]
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}


class DataError(ValueError):
    pass


def bucket(tier, t):
    if tier == "hourly":
        return t.strftime("%Y-%m-%d %H")
    if tier == "daily":
        return t.date().isoformat()
    if tier == "weekly":
        y, w, _ = t.isocalendar()
        return f"{y}-W{w:02d}"
    if tier == "monthly":
        return t.strftime("%Y-%m")
    return str(t.year)


def simulate(policy, interval_h, today):
    """Return (kept list of (datetime, [tiers])) newest first."""
    for k in policy:
        if k not in TIERS:
            raise DataError(f"unknown policy tier '{k}' (use {', '.join(TIERS)})")
        if not isinstance(policy[k], int) or policy[k] < 0:
            raise DataError(f"policy.{k} must be a whole number >= 0")
    if sum(policy.get(t, 0) for t in TIERS) == 0:
        raise DataError("policy keeps nothing")
    if not interval_h or interval_h <= 0:
        raise DataError("backup_interval_hours must be > 0")
    span_days = max(policy.get("hourly", 0) / 24, policy.get("daily", 0), policy.get("weekly", 0) * 7,
                    policy.get("monthly", 0) * 31, policy.get("yearly", 0) * 366) + 400
    newest = dt.datetime.combine(today, dt.time(2, 0))
    step = dt.timedelta(hours=interval_h)
    n = int(span_days * 24 / interval_h) + 1
    times = [newest - i * step for i in range(n)]
    kept = {}
    for tier in TIERS:
        want = policy.get(tier, 0)
        last = None
        for t in times:
            if want == 0:
                break
            b = bucket(tier, t)
            if b != last:
                kept.setdefault(t, []).append(tier)
                last = b
                want -= 1
    return sorted(kept.items(), key=lambda kv: kv[0], reverse=True)


def storage_gb(kept, size):
    full = float(size.get("full_gb", 0))
    model = size.get("storage_model", "incremental")
    if model not in ("incremental", "full"):
        raise DataError("size.storage_model must be 'incremental' or 'full'")
    if model == "full":
        return full * len(kept)
    change = float(size.get("daily_change_pct", 5)) / 100
    total = full
    for (newer, _), (older, _) in zip(kept, kept[1:]):
        days = (newer - older).total_seconds() / 86400
        total += full * min(1.0, change * days)
    return total


def plan(cfg, today=None):
    try:
        today = today or dt.date.fromisoformat(cfg.get("today") or dt.date.today().isoformat())
    except ValueError:
        raise DataError("today must be YYYY-MM-DD")
    policy = cfg.get("policy") or {}
    interval = float(cfg.get("backup_interval_hours", 24))
    kept = simulate(policy, interval, today)
    newest = kept[0][0]
    ages = [(newest - t).total_seconds() / 86400 for t, _ in kept]
    oldest_days = ages[-1]
    size = cfg.get("size") or {}
    req = cfg.get("requirements") or {}
    copies = cfg.get("copies") or {}
    price = float(cfg.get("price_per_gb_month", 0))
    gb = storage_gb(kept, size)
    growth = float(size.get("growth_pct_per_month", 0)) / 100
    gb12 = gb * (1 + growth) ** 12
    tiers = []
    for tier in TIERS:
        pts = [(t, a) for (t, tl), a in zip(kept, ages) if tier in tl]
        if pts:
            tiers.append({"tier": tier, "count": len(pts), "newest_age_days": round(pts[0][1], 1),
                          "oldest_age_days": round(pts[-1][1], 1)})
    gaps = []
    for (t1, _), (t2, _), a in zip(kept, kept[1:], ages[1:]):
        gaps.append({"from": t2.date().isoformat(), "to": t1.date().isoformat(),
                     "gap_days": round((t1 - t2).total_seconds() / 86400, 1), "age_days": round(a, 1)})
    findings = []

    def add(sev, code, msg):
        findings.append({"severity": sev, "code": code, "message": msg})

    rpo = req.get("rpo_hours")
    if rpo and interval > rpo:
        add("HIGH", "RPO_MISSED", f"backups every {interval:g} h but RPO is {rpo:g} h; up to {interval:g} h of data can be lost")
    mh = req.get("min_history_days")
    if mh and oldest_days < mh:
        add("HIGH", "HISTORY_SHORT", f"oldest restore point is {oldest_days:.0f} days old; requirement is {mh} days")
    ly = req.get("legal_retention_years")
    if ly and oldest_days < ly * 365:
        add("HIGH", "LEGAL_RETENTION", f"oldest point {oldest_days / 365:.1f} years; legal retention needs {ly} years; yearly N also counts the current year, so set yearly >= {ly + 2} to cover it all year round")
    if not copies.get("offsite"):
        add("HIGH", "NO_OFFSITE", "no offsite copy; a site loss, account compromise or region outage takes every backup")
    dur = cfg.get("backup_duration_hours")
    if dur and dur > interval:
        add("HIGH", "OVERLAP", f"backup takes {dur:g} h but runs every {interval:g} h; jobs overlap or get skipped")
    elif dur and dur > interval / 2:
        add("LOW", "TIGHT_WINDOW", f"backup takes {dur:g} h of a {interval:g} h interval; growth will push it into overlap")
    lrt = cfg.get("last_restore_test")
    max_age = req.get("restore_test_max_age_days", 90)
    if not lrt:
        add("HIGH", "NEVER_RESTORED", "no restore test recorded; an untested backup is a hope, not a backup")
    else:
        age = (today - dt.date.fromisoformat(lrt)).days
        if age > max_age:
            add("MEDIUM", "RESTORE_TEST_OLD", f"last restore test {age} days ago (limit {max_age}); schedule a timed restore")
    if not copies.get("immutable"):
        add("MEDIUM", "NO_IMMUTABLE", "no immutable or air-gapped copy; ransomware or a stolen admin key can delete every backup")
    if copies.get("offsite") and copies.get("encrypted") is False:
        add("MEDIUM", "NOT_ENCRYPTED", "offsite copy is not encrypted at rest")
    mx = req.get("max_storage_gb")
    if mx and max(gb, gb12) > mx:
        add("MEDIUM", "STORAGE_OVER", f"storage {gb:.0f} GB now, {gb12:.0f} GB in 12 months; budget {mx} GB")
    order = [t for t in TIERS if policy.get(t, 0) > 0]
    span = {"hourly": 1 / 24, "daily": 1, "weekly": 7, "monthly": 30.4, "yearly": 365}
    for fine, coarse in zip(order, order[1:]):
        fine_end = policy[fine] * span[fine]
        if span[coarse] >= 4 * fine_end and fine != "hourly":
            add("LOW", "TIER_GAP", f"{fine} points cover ~{fine_end:.0f} days, then only one {coarse} point per {span[coarse]:.0f} days")
    findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
    return {
        "name": cfg.get("name", "backup set"), "today": today.isoformat(), "interval_hours": interval,
        "restore_points": len(kept), "oldest_age_days": round(oldest_days, 1),
        "tiers": tiers, "largest_gaps": sorted(gaps, key=lambda g: -g["gap_days"])[:5],
        "storage_gb": round(gb, 1), "storage_gb_12m": round(gb12, 1),
        "monthly_cost": round(gb * price, 2), "monthly_cost_12m": round(gb12 * price, 2),
        "currency": cfg.get("currency", "USD"), "storage_model": size.get("storage_model", "incremental"),
        "kept": [{"time": t.strftime("%Y-%m-%d %H:%M"), "tiers": tl} for t, tl in kept],
        "findings": findings,
    }


def age_label(d):
    if d < 2:
        return f"{d * 24:.0f} h"
    if d < 60:
        return f"{d:.0f} d"
    if d < 730:
        return f"{d / 30.4:.1f} mo"
    return f"{d / 365:.1f} y"


def render(r):
    out = [f"# Backup retention plan: {r['name']} (as of {r['today']})", "",
           f"Backups every {r['interval_hours']:g} h -> {r['restore_points']} restore points kept, oldest {age_label(r['oldest_age_days'])} old",
           f"Storage ({r['storage_model']}): {r['storage_gb']:.0f} GB now, {r['storage_gb_12m']:.0f} GB in 12 months"
           f" -> {r['monthly_cost']:.2f} -> {r['monthly_cost_12m']:.2f} {r['currency']}/month", "",
           "| Tier | Points | Newest age | Oldest age |", "|---|---|---|---|"]
    for t in r["tiers"]:
        out.append(f"| {t['tier']} | {t['count']} | {age_label(t['newest_age_days'])} | {age_label(t['oldest_age_days'])} |")
    out += ["", "## Largest gaps between restore points", "| Older point | Newer point | Gap | Age of older point |", "|---|---|---|---|"]
    for g in r["largest_gaps"]:
        out.append(f"| {g['from']} | {g['to']} | {g['gap_days']:g} d | {age_label(g['age_days'])} |")
    out += ["", "## Restore points kept"]
    out.append(", ".join(f"{k['time'][:10]} ({'/'.join(x[0].upper() for x in k['tiers'])})" for k in r["kept"]))
    out += ["", "## Findings"]
    if not r["findings"]:
        out.append("- none")
    for f in r["findings"]:
        out.append(f"- {f['severity']} {f['code']}: {f['message']}")
    return "\n".join(out) + "\n"


def self_test():
    ok = 0
    tests = []

    def check(name, cond):
        nonlocal ok
        tests.append(name)
        print(("PASS " if cond else "FAIL ") + name)
        ok += bool(cond)

    today = dt.date(2026, 10, 24)
    base = {"today": "2026-10-24", "backup_interval_hours": 24, "policy": {"daily": 7, "weekly": 4, "monthly": 12},
            "size": {"full_gb": 100, "daily_change_pct": 2}, "requirements": {"rpo_hours": 24, "min_history_days": 35},
            "copies": {"offsite": True, "immutable": True, "encrypted": True}, "last_restore_test": "2026-10-01"}
    r = plan(base, today)
    # daily 7 = 7 newest days; weekly 4 overlaps with dailies; monthly 12
    check("daily 7 + weekly 4 + monthly 12 -> 20 distinct points", r["restore_points"] == 20)
    check("clean policy has no findings", r["findings"] == [])
    check("oldest point is the 2025-11-30 monthly", r["kept"][-1]["time"].startswith("2025-11-30"))
    k = simulate({"daily": 3}, 24, today)
    check("daily 3 keeps 3 newest days", [t.date().day for t, _ in k] == [24, 23, 22])
    k = simulate({"hourly": 6}, 1, today)
    check("hourly 6 keeps 6 points one hour apart", len(k) == 6 and (k[0][0] - k[5][0]) == dt.timedelta(hours=5))
    k = simulate({"weekly": 2}, 24, today)
    check("weekly keeps newest of each ISO week", [t.date().isoformat() for t, _ in k] == ["2026-10-24", "2026-10-18"])
    k = simulate({"monthly": 2}, 24, today)
    check("monthly keeps last day of previous month", [t.date().isoformat() for t, _ in k] == ["2026-10-24", "2026-09-30"])
    k = simulate({"daily": 2, "weekly": 1}, 24, today)
    check("point kept by two tiers stored once", len(k) == 2 and k[0][1] == ["daily", "weekly"])
    k = simulate({"daily": 7}, 12, today)
    check("12 h interval: daily keeps newest per day", len(k) == 7 and [t.hour for t, _ in k[:2]] == [2, 14])
    check("full storage = points x full", storage_gb([(dt.datetime(2026, 1, 2), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 50, "storage_model": "full"}) == 100)
    check("incremental storage = full + change x days", abs(storage_gb([(dt.datetime(2026, 1, 11), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 100, "daily_change_pct": 2}) - 120) < 1e-9)
    check("incremental change capped at 100%", storage_gb([(dt.datetime(2026, 6, 1), []), (dt.datetime(2026, 1, 1), [])], {"full_gb": 10, "daily_change_pct": 5}) == 20)

    def codes(**over):
        c = json.loads(json.dumps(base))
        for key, val in over.items():
            if key != "policy" and isinstance(val, dict) and isinstance(c.get(key), dict):
                c[key].update(val)
            else:
                c[key] = val
        return {f["code"] for f in plan(c, today)["findings"]}

    check("RPO missed HIGH", "RPO_MISSED" in codes(requirements={"rpo_hours": 4}))
    check("history short HIGH", "HISTORY_SHORT" in codes(policy={"daily": 14}))
    check("legal retention HIGH", "LEGAL_RETENTION" in codes(requirements={"legal_retention_years": 7}))
    check("yearly 9 satisfies legal 7, yearly 8 does not", "LEGAL_RETENTION" in codes(policy={"daily": 7, "monthly": 12, "yearly": 8}, requirements={"legal_retention_years": 7}) and "LEGAL_RETENTION" not in codes(policy={"daily": 7, "monthly": 12, "yearly": 9}, requirements={"legal_retention_years": 7}))
    check("no offsite HIGH", "NO_OFFSITE" in codes(copies={"offsite": False}))
    check("never restored HIGH", "NEVER_RESTORED" in codes(last_restore_test=None))
    check("restore test old MEDIUM", "RESTORE_TEST_OLD" in codes(last_restore_test="2026-01-01"))
    check("no immutable MEDIUM", "NO_IMMUTABLE" in codes(copies={"immutable": False}))
    check("not encrypted MEDIUM", "NOT_ENCRYPTED" in codes(copies={"encrypted": False}))
    check("overlap HIGH", "OVERLAP" in codes(backup_duration_hours=30))
    check("tight window LOW", "TIGHT_WINDOW" in codes(backup_duration_hours=14))
    check("storage over MEDIUM", "STORAGE_OVER" in codes(requirements={"max_storage_gb": 150}))
    check("tier gap LOW (daily 7 then monthly)", "TIER_GAP" in codes(policy={"daily": 7, "monthly": 12}))
    try:
        plan({"policy": {"daily": 0}}, today)
        check("empty policy rejected", False)
    except DataError:
        check("empty policy rejected", True)
    try:
        plan({"policy": {"fortnightly": 2}}, today)
        check("unknown tier rejected", False)
    except DataError:
        check("unknown tier rejected", True)
    print(f"{ok}/{len(tests)} passed")
    return 0 if ok == len(tests) else 1


def main():
    ap = argparse.ArgumentParser(description="Backup retention (GFS) policy planner")
    ap.add_argument("policy", nargs="?", help="policy JSON file")
    ap.add_argument("--json", action="store_true", help="print JSON instead of Markdown")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.policy:
        ap.error("policy file required")
    try:
        with open(a.policy, encoding="utf-8") as fh:
            cfg = json.load(fh)
        r = plan(cfg)
    except (OSError, json.JSONDecodeError, DataError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(json.dumps(r, indent=2) if a.json else render(r), end="" if not a.json else "\n")
    return 1 if any(f["severity"] == "HIGH" for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
PythonDevOpsDatabase+2
F@f
0