diff --git a/monitor/sre-agent-event-lab/README.md b/monitor/sre-agent-event-lab/README.md index bb0266ec..68ccb643 100644 --- a/monitor/sre-agent-event-lab/README.md +++ b/monitor/sre-agent-event-lab/README.md @@ -113,6 +113,10 @@ postprovision 단계가 실패하면 로컬 환경만 실패한 것입니다. `. ## 시나리오 실행 +각 시나리오 문서는 **수동 실행**을 먼저 설명합니다. `az containerapp update`, `az role assignment delete`처럼 실제로 Azure에 적용되는 명령을 그대로 실행하면서 무엇이 바뀌는지 확인하는 경로입니다. 처음 진행할 때는 이 경로를 권장합니다. + +같은 절차를 한 번에 실행하는 지름길도 각 문서 뒤쪽에 있습니다. + ```bash ./scripts/lab.sh run s1 ./scripts/lab.sh capture s1 @@ -122,7 +126,7 @@ postprovision 단계가 실패하면 로컬 환경만 실패한 것입니다. `. ./scripts/lab.sh capture s3 ``` -`run-scenario.sh`와 `capture-scenario.sh`는 `scripts/common.sh`의 `load_lab_config`로 "명시적 환경 변수 > 현재 `azd env get-value` > 허용된 기본값" 순서로 설정을 읽으므로, 고정된 구독이나 리소스 그룹이 스크립트 안에 없습니다. 진행 상태는 현재 azd 환경에 묶인 `evidence/state.json`에 기록되며 순서를 어기면 실행이 거부됩니다. 순서와 별개로, 어떤 시나리오든 실행이 `running`이나 `failed`로 남아 있으면 세 시나리오 모두 새 실행이 거부됩니다. 세 시나리오는 Container App 하나를 공유하므로, 끝나지 않은 실행 하나가 남은 실습 전체를 막습니다. +`run-scenario.sh`와 `capture-scenario.sh`는 `scripts/common.sh`의 `load_lab_config`로 "명시적 환경 변수 > 현재 `azd env get-value` > 허용된 기본값" 순서로 설정을 읽으므로, 고정된 구독이나 리소스 그룹이 스크립트 안에 없습니다. 진행 상태는 현재 azd 환경에 묶인 `evidence/state.json`에 기록되며 순서를 어기면 실행이 거부됩니다. 순서와 별개로, 어떤 시나리오든 실행이 `running`이나 `failed`로 남아 있으면 세 시나리오 모두 새 실행이 거부됩니다. 세 시나리오는 Container App 하나를 공유하므로, 끝나지 않은 실행 하나가 남은 실습 전체를 막습니다. 수동 실행도 첫 단계에서 `lab_state.py begin-run`을 호출해 같은 게이트를 적용받습니다. 차이는 복구입니다. 지름길은 종료 트랩이 장애를 자동으로 되돌리지만, 수동 실행에서는 복구 명령을 직접 완료해야 합니다. | 시나리오 | 주입하는 장애 | 안내 문서 | |---|---|---| diff --git a/monitor/sre-agent-event-lab/guides/02-scenario-s1.md b/monitor/sre-agent-event-lab/guides/02-scenario-s1.md index 8ccd1e2a..1a04f020 100644 --- a/monitor/sre-agent-event-lab/guides/02-scenario-s1.md +++ b/monitor/sre-agent-event-lab/guides/02-scenario-s1.md @@ -7,33 +7,302 @@ - [01-agent-setup.md](01-agent-setup.md)를 마쳤고 `evidence/state.json`에 `baseline_passed`와 `agent_setup_acknowledged`가 기록되어 있습니다. - 현재 활성 구독이 azd 환경의 구독과 같습니다. -이 두 가지만 `evidence/state.json`을 통해 실제로 강제됩니다. 여기에 더해, 어떤 시나리오든 실행이 `running`이나 `failed`로 남아 있으면 세 시나리오 모두 새 실행이 거부됩니다. 세 시나리오는 같은 Container App 하나를 쓰고, 끝나지 않은 실행은 장애가 아직 살아 있을 수 있는 상태이기 때문입니다. 거부 메시지는 어떤 시나리오가 어떤 상태로 막고 있는지와 해결 명령을 함께 알려 줍니다. `failed`는 그 시나리오를 다시 실행하면 풀리고, `running`은 실행이 끝나기를 기다리거나 `python3 scripts/lab_state.py mark-failed s1`처럼 끝난 방식을 기록해야 풀립니다. 잠금까지 제공하지는 않으므로(1인 운영자 전제) 두 터미널에서 같은 명령을 정확히 동시에 띄우는 경우는 여전히 운영자가 피해야 합니다. +이 두 가지는 `evidence/state.json`을 통해 강제됩니다. 아래 "수동 실행"도 첫 단계에서 `lab_state.py begin-run`을 호출하므로 같은 순서·중복 실행 게이트를 그대로 적용받습니다. 차이는 실패했을 때입니다. 지름길은 종료 트랩이 장애를 자동으로 되돌리지만, 수동 실행에서는 복구 명령을 운영자가 직접 실행해야 합니다. 조건이 하나라도 없으면 실행이 시작 전에 거부되고 무엇을 먼저 하라는 안내가 출력됩니다. -## 실행 명령 +## 수동 실행 + +이 절의 명령을 순서대로 실행하면 스크립트 없이 시나리오 하나를 끝까지 진행할 수 있습니다. 무엇이 Azure에 적용되는지 명령 그대로 보이는 것이 목적입니다. 각 블록은 앞 단계가 성공했는지 플래그로 확인하고 진행합니다. + +먼저 이번 실행에서 계속 쓸 값을 셸 변수로 읽습니다. 아래 블록부터 마지막 `record-capture`까지는 같은 셸에서 실행합니다. ```bash cd monitor/sre-agent-event-lab -./scripts/lab.sh run s1 +LAB_READY=1 + +RESOURCE_GROUP="$(azd env get-value AZURE_RESOURCE_GROUP 2>/dev/null)" || LAB_READY=0 +SUBSCRIPTION_ID="$(azd env get-value AZURE_SUBSCRIPTION_ID 2>/dev/null)" || LAB_READY=0 +APP_NAME="$(azd env get-value AZURE_CONTAINER_APP_NAME 2>/dev/null)" || LAB_READY=0 +APP_FQDN="$(azd env get-value AZURE_CONTAINER_APP_FQDN 2>/dev/null)" || LAB_READY=0 + +ALERT_RULE_NAME="alert-sre-lab-s1-http500" +EVIDENCE_DIR="${PWD}/evidence/s1-$(date -u +%Y%m%dT%H%M%SZ)" + +for value in "${RESOURCE_GROUP}" "${SUBSCRIPTION_ID}" "${APP_NAME}" "${APP_FQDN}"; do + [[ -n "${value// /}" ]] || LAB_READY=0 +done + +if (( LAB_READY )); then + mkdir -p "${EVIDENCE_DIR}" + python3 scripts/lab_state.py begin-run s1 "${EVIDENCE_DIR}" || LAB_READY=0 +else + echo "azd 환경 값을 읽지 못했습니다. 먼저 azd provision을 실행하세요." >&2 +fi +(( LAB_READY )) || echo "준비되지 않았습니다. 아래 단계는 모두 건너뜁니다." >&2 ``` -한 번의 실행이 장애 주입 → 부하 → 경고 대기 → 복구 → 타임라인 저장까지 진행합니다. 경고가 발생하지 않으면 12분 뒤 실패로 기록하고 종료합니다. 스크립트를 중간에 끊어도 종료 트랩이 복구를 시도합니다. +`azd env get-value`는 실패해도 오류 문장을 표준 출력으로 내보내므로 위 검사가 필요합니다. `begin-run`은 이번 시도를 기록하면서 순서·중복 실행 게이트도 함께 적용하고, 거부되면 `LAB_READY`가 0이 되어 이후 단계가 모두 건너뜁니다. 근거 디렉터리를 절대 경로로 두는 이유는 채점기와 캡처 도구가 기록된 경로를 실행 위치와 무관하게 다시 열기 때문입니다. + +### 1. 장애 주입 -경고가 발생하고 복구까지 끝나면 조사 근거를 수집합니다. +`FAILURE_MODE=http500`을 설정하면 새 revision이 배포되고 `/api/orders`가 500을 반환합니다. ```bash -./scripts/lab.sh capture s1 +INJECTED=0 +INJECTED_AT="" +if (( LAB_READY )); then + OLD_REVISION="$(az containerapp show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query properties.latestRevisionName -o tsv)" + + az containerapp update \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --set-env-vars FAILURE_MODE=http500 \ + --output none \ + && INJECTED=1 && INJECTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +fi +``` + +부하를 넣기 전에 **새** revision이 활성·정상이 될 때까지 최대 10분 기다립니다. 이 확인을 건너뛰면 이전 revision에 부하가 들어가 경고가 발생하지 않습니다. + +```bash +REVISION_OK=0 +if (( INJECTED )); then + DEADLINE=$(( SECONDS + 600 )) + while (( SECONDS < DEADLINE )); do + NEW_REVISION="$(az containerapp show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query properties.latestRevisionName -o tsv)" + STATE="$(az containerapp revision list \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query "[?name=='${NEW_REVISION}'].{health:properties.healthState, active:properties.active} | [0]" -o json)" + echo "${NEW_REVISION} ${STATE}" + if [[ -n "${NEW_REVISION}" && "${NEW_REVISION}" != "${OLD_REVISION}" ]] \ + && [[ "$(jq -r '.health // empty' <<<"${STATE}")" == "Healthy" ]] \ + && [[ "$(jq -r '.active // empty' <<<"${STATE}")" == "true" ]]; then + REVISION_OK=1 + break + fi + sleep 10 + done + (( REVISION_OK )) || echo "새 revision이 시간 안에 정상이 되지 않았습니다." >&2 +fi +``` + +### 2. 부하 발생 + +경고 임계값을 넘길 만큼의 500 응답을 만듭니다. `loadgen.py`는 요청을 보내고 상태 코드를 집계하는 것 외에는 하지 않습니다. + +```bash +if (( REVISION_OK )); then + python3 scripts/loadgen.py \ + "https://${APP_FQDN}/api/orders" \ + --requests 120 \ + --concurrency 4 \ + --expect-status 500 \ + --output "${EVIDENCE_DIR}/load.json" +else + echo "장애가 적용되지 않아 부하를 넣지 않습니다. 4단계 복구로 넘어가세요." >&2 +fi ``` -수집 대상 디렉터리는 방금 실행이 `evidence/state.json`에 기록한 값에서 정해지므로 경로를 직접 입력하지 않습니다. +### 3. 경고 확인 + +Sev2 경고가 발생할 때까지 최대 12분 기다립니다. 보통 1~3분 걸립니다. + +```bash +ALERT_ID="" +ALERT_FIRED_AT="" +if (( REVISION_OK )); then + DEADLINE=$(( SECONDS + 720 )) + while (( SECONDS < DEADLINE )); do + ALERT_JSON="$(az rest \ + --method get \ + --url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/providers/Microsoft.AlertsManagement/alerts?api-version=2019-03-01&targetResourceGroup=${RESOURCE_GROUP}&monitorCondition=Fired" \ + --query "value[?contains(properties.essentials.alertRule, '${ALERT_RULE_NAME}')] | [0].{id:id, started:properties.essentials.startDateTime}" \ + -o json)" + ALERT_ID="$(jq -r '.id // empty' <<<"${ALERT_JSON}")" + ALERT_FIRED_AT="$(jq -r '.started // empty' <<<"${ALERT_JSON}")" + [[ -n "${ALERT_ID}" ]] && break + sleep 20 + done +fi +echo "${ALERT_ID:-경고가 아직 없습니다}" +``` + +경고가 끝내 발생하지 않아도 4단계 복구는 반드시 실행합니다. 5단계의 조건 분기가 실패로 기록합니다. + +### 4. 복구 -같은 UTC 구간의 KQL 근거를 따로 내보내려면 실행이 남긴 `timeline.json`의 시각을 그대로 넣어 호출합니다. +장애를 되돌립니다. 이 명령을 잊으면 워크로드는 계속 500을 반환합니다. ```bash -./scripts/query-evidence.sh s1 evidence/s1-<타임스탬프> <시작 UTC> <끝 UTC> +RECOVERY_OK=0 +if (( INJECTED )); then +RECOVERY_OLD_REVISION="$(az containerapp show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query properties.latestRevisionName -o tsv)" + +az containerapp update \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --set-env-vars FAILURE_MODE=none \ + --output none + +REVISION_OK=0 +DEADLINE=$(( SECONDS + 600 )) +while (( SECONDS < DEADLINE )); do + NEW_REVISION="$(az containerapp show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query properties.latestRevisionName -o tsv)" + STATE="$(az containerapp revision list \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query "[?name=='${NEW_REVISION}'].{health:properties.healthState, active:properties.active} | [0]" -o json)" + echo "${NEW_REVISION} ${STATE}" + if [[ -n "${NEW_REVISION}" && "${NEW_REVISION}" != "${RECOVERY_OLD_REVISION}" ]] \ + && [[ "$(jq -r '.health // empty' <<<"${STATE}")" == "Healthy" ]] \ + && [[ "$(jq -r '.active // empty' <<<"${STATE}")" == "true" ]]; then + REVISION_OK=1 + break + fi + sleep 10 +done +(( REVISION_OK )) || echo "새 revision이 시간 안에 정상이 되지 않았습니다." >&2 +RECOVERY_OK=${REVISION_OK} +RECOVERED_AT="" +(( RECOVERY_OK )) && RECOVERED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +curl -s --max-time 15 -o /dev/null -w '%{http_code}\n' "https://${APP_FQDN}/api/orders" +else + echo "주입된 장애가 없어 복구를 건너뜁니다." >&2 + RECOVERY_OK=1 +fi ``` +`RECOVERY_OK`가 0이면 장애가 아직 살아 있다는 뜻입니다. 다음 시나리오로 넘어가기 전에 `FAILURE_MODE=none`을 직접 다시 적용하고 revision 상태를 확인하세요. + +경고가 `Resolved`로 바뀔 때까지 기다립니다. 1분 주기 stateful log alert는 조건이 10분간 불충족이어야 해제되므로 최대 25분까지 걸립니다. + +```bash +ALERT_CONDITION="" +if [[ -n "${ALERT_ID}" ]]; then + DEADLINE=$(( SECONDS + 1500 )) + while (( SECONDS < DEADLINE )); do + ALERT_CONDITION="$(az rest \ + --method get \ + --url "https://management.azure.com${ALERT_ID}?api-version=2019-03-01" \ + --query "properties.essentials.monitorCondition" -o tsv)" + echo "${ALERT_CONDITION}" + [[ "${ALERT_CONDITION}" == "Resolved" ]] && break + sleep 20 + done +fi +``` + +### 5. 실행 결과 기록 + +채점기가 읽는 상태와 타임라인을 남깁니다. + +```bash +if (( LAB_READY )); then +ALERT_RESOLVED_AT="" +[[ "${ALERT_CONDITION}" == "Resolved" ]] && ALERT_RESOLVED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +jq -n \ + --arg scenario s1 \ + --arg injectedAt "${INJECTED_AT}" \ + --arg alertRule "${ALERT_RULE_NAME}" \ + --arg alertId "${ALERT_ID}" \ + --arg alertFiredAt "${ALERT_FIRED_AT}" \ + --arg recoveredAt "${RECOVERED_AT}" \ + --arg alertResolvedAt "${ALERT_RESOLVED_AT}" \ + '{scenario: $scenario, injected_at: $injectedAt, alert_rule: $alertRule, + alert_id: $alertId, alert_fired_at: $alertFiredAt, recovered_at: (if $recoveredAt == "" then null else $recoveredAt end), + alert_resolved_at: (if $alertResolvedAt == "" then null else $alertResolvedAt end)}' \ + > "${EVIDENCE_DIR}/timeline.json" + +if (( RECOVERY_OK )) && [[ "${ALERT_CONDITION}" == "Resolved" ]]; then + python3 scripts/lab_state.py mark-recovered s1 "${EVIDENCE_DIR}" +else + python3 scripts/lab_state.py mark-failed s1 "${EVIDENCE_DIR}" \ + --reason "recovery_ok=${RECOVERY_OK} alert=${ALERT_CONDITION:-unknown}" +fi +fi +``` + +복구는 워크로드 정상화(`RECOVERY_OK`)와 경고 해제가 모두 확인될 때만 인정합니다. 둘 중 하나라도 어긋나면 실패로 기록되므로, 장애가 남아 있는 실행이 성공으로 채점되지 않습니다. + +`timeline.json`은 캡처 도구가 대상 경고를 찾는 파일이며, 이 파일이 없으면 지름길의 `lab.sh capture`도 진행하지 못합니다. + +### 6. 조사 근거 수집 + +Agent 스레드를 내려받아 정규화하고, 관측 결과를 먼저 기록한 뒤 그림을 만듭니다. + +```bash +AGENT_ENDPOINT="$(jq -r '.agent_endpoint // empty' evidence/agent-setup.json)" +case "${AGENT_ENDPOINT}" in + https://*"<"* | https://*">"* | "https://") AGENT_ENDPOINT="" ;; + https://*) ;; + *) AGENT_ENDPOINT="" ;; +esac + +if [[ -z "${ALERT_ID}" || -z "${AGENT_ENDPOINT}" ]]; then + echo "ALERT_ID가 없거나 agent_endpoint가 https URL이 아니어서 캡처를 건너뜁니다." >&2 +else + app/.venv/bin/python scripts/capture_agent.py \ + --scenario s1 \ + --alert-id "${ALERT_ID}" \ + --endpoint "${AGENT_ENDPOINT}" \ + --output-dir "${EVIDENCE_DIR}" \ + --timeout 1200 \ + --interval 15 || true + + if [[ -f "${EVIDENCE_DIR}/normalized-timeline.json" ]]; then + python3 scripts/lab_state.py record-capture s1 \ + --timeline "${EVIDENCE_DIR}/normalized-timeline.json" \ + --evidence-dir "${EVIDENCE_DIR}" + + app/.venv/bin/python scripts/render_capture.py \ + "${EVIDENCE_DIR}/normalized-timeline.json" \ + assets/captures/s1 \ + --scenario s1 + else + echo "정규화된 타임라인이 없어 캡처를 기록하지 않았습니다." >&2 + fi +fi +``` + +`agent_endpoint`는 `https://`로 시작하고 자리표시자 괄호가 없어야 합니다. `http://`를 쓰면 데이터 평면 토큰이 평문으로 나갑니다. `capture_agent.py`는 제한 시간까지 결론을 받지 못하면 종료 코드 3으로 끝나며, 이는 "결론 없음"을 그대로 기록하는 정상 경로입니다. 결과 기록을 렌더링보다 먼저 하는 이유는 이미지 생성이 실패해도 관측한 결과를 잃지 않기 위해서입니다. + +같은 UTC 구간의 KQL 근거가 필요하면 다음을 실행합니다. + +```bash +if (( LAB_READY )) && [[ -n "${INJECTED_AT}" ]]; then + ./scripts/query-evidence.sh s1 "${EVIDENCE_DIR}" \ + "${INJECTED_AT}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +fi +``` + +## 지름길 + +위 여섯 단계를 그대로 자동화한 것이 아래 두 명령입니다. 같은 Azure 호출을 같은 순서로 실행하고, 추가로 실행 상태 기록과 종료 시 자동 복구를 처리합니다. 무엇이 실행되는지 이미 알고 반복할 때만 쓰세요. + +```bash +./scripts/lab.sh run s1 +./scripts/lab.sh capture s1 +``` + +`run`은 장애 주입 → 부하 → 경고 대기 → 복구 → 타임라인 저장까지 진행하고, 경고가 발생하지 않으면 12분 뒤 실패로 기록합니다. 중간에 끊어도 종료 트랩이 복구를 시도합니다. `capture`는 대상 디렉터리를 `evidence/state.json`에서 찾으므로 경로를 입력하지 않습니다. + +지름길은 순서 게이트도 적용합니다. 어떤 시나리오든 실행이 `running`이나 `failed`로 남아 있으면 세 시나리오 모두 새 실행이 거부됩니다. 세 시나리오는 같은 Container App 하나를 쓰고, 끝나지 않은 실행은 장애가 아직 살아 있을 수 있는 상태이기 때문입니다. `failed`는 그 시나리오를 다시 실행하면 풀리고, `running`은 실행이 끝나기를 기다리거나 `python3 scripts/lab_state.py mark-failed s1`처럼 끝난 방식을 기록해야 풀립니다. 수동 실행도 `begin-run`으로 같은 게이트를 적용받지만, 장애를 되돌리는 일은 운영자가 직접 해야 합니다. + ## Azure에서 발생하는 변화 | 순서 | 변화 | diff --git a/monitor/sre-agent-event-lab/guides/03-scenario-s2.md b/monitor/sre-agent-event-lab/guides/03-scenario-s2.md index 29a89a69..0665ae2c 100644 --- a/monitor/sre-agent-event-lab/guides/03-scenario-s2.md +++ b/monitor/sre-agent-event-lab/guides/03-scenario-s2.md @@ -9,14 +9,268 @@ - 다른 시나리오의 실행이 `running`이나 `failed`로 남아 있지 않습니다. 하나라도 남아 있으면 S2도 거부되고, 거부 메시지가 막고 있는 시나리오와 해결 명령을 알려 줍니다. - 워크로드가 정상이고 S1 경고가 해제되어 있습니다. -## 실행 명령 +## 수동 실행 + +S1과 같은 구조이며, 바뀌는 것은 주입 값과 부하 조건뿐입니다. 아래 블록부터 마지막 `record-capture`까지 같은 셸에서 실행합니다. ```bash cd monitor/sre-agent-event-lab +LAB_READY=1 + +RESOURCE_GROUP="$(azd env get-value AZURE_RESOURCE_GROUP 2>/dev/null)" || LAB_READY=0 +SUBSCRIPTION_ID="$(azd env get-value AZURE_SUBSCRIPTION_ID 2>/dev/null)" || LAB_READY=0 +APP_NAME="$(azd env get-value AZURE_CONTAINER_APP_NAME 2>/dev/null)" || LAB_READY=0 +APP_FQDN="$(azd env get-value AZURE_CONTAINER_APP_FQDN 2>/dev/null)" || LAB_READY=0 + +ALERT_RULE_NAME="alert-sre-lab-s2-latency" +EVIDENCE_DIR="${PWD}/evidence/s2-$(date -u +%Y%m%dT%H%M%SZ)" + +for value in "${RESOURCE_GROUP}" "${SUBSCRIPTION_ID}" "${APP_NAME}" "${APP_FQDN}"; do + [[ -n "${value// /}" ]] || LAB_READY=0 +done + +if (( LAB_READY )); then + mkdir -p "${EVIDENCE_DIR}" + python3 scripts/lab_state.py begin-run s2 "${EVIDENCE_DIR}" || LAB_READY=0 +else + echo "azd 환경 값을 읽지 못했습니다. 먼저 azd provision을 실행하세요." >&2 +fi +(( LAB_READY )) || echo "준비되지 않았습니다. 아래 단계는 모두 건너뜁니다." >&2 +``` + +`azd env get-value`는 실패해도 오류 문장을 표준 출력으로 내보내므로 위 검사가 필요합니다. `begin-run`은 이번 시도를 기록하면서 순서·중복 실행 게이트도 함께 적용하고, 거부되면 `LAB_READY`가 0이 되어 이후 단계가 모두 건너뜁니다. 근거 디렉터리를 절대 경로로 두는 이유는 채점기와 캡처 도구가 기록된 경로를 실행 위치와 무관하게 다시 열기 때문입니다. + +### 1. 지연 주입 + +```bash +INJECTED=0 +INJECTED_AT="" +if (( LAB_READY )); then + OLD_REVISION="$(az containerapp show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query properties.latestRevisionName -o tsv)" + + az containerapp update \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --set-env-vars ORDER_DELAY_MS=4000 \ + --output none \ + && INJECTED=1 && INJECTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +fi +``` + +부하를 넣기 전에 **새** revision이 활성·정상이 될 때까지 최대 10분 기다립니다. 이 확인을 건너뛰면 이전 revision에 부하가 들어가 경고가 발생하지 않습니다. + +```bash +REVISION_OK=0 +if (( INJECTED )); then + DEADLINE=$(( SECONDS + 600 )) + while (( SECONDS < DEADLINE )); do + NEW_REVISION="$(az containerapp show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query properties.latestRevisionName -o tsv)" + STATE="$(az containerapp revision list \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query "[?name=='${NEW_REVISION}'].{health:properties.healthState, active:properties.active} | [0]" -o json)" + echo "${NEW_REVISION} ${STATE}" + if [[ -n "${NEW_REVISION}" && "${NEW_REVISION}" != "${OLD_REVISION}" ]] \ + && [[ "$(jq -r '.health // empty' <<<"${STATE}")" == "Healthy" ]] \ + && [[ "$(jq -r '.active // empty' <<<"${STATE}")" == "true" ]]; then + REVISION_OK=1 + break + fi + sleep 10 + done + (( REVISION_OK )) || echo "새 revision이 시간 안에 정상이 되지 않았습니다." >&2 +fi +``` + +### 2. 부하 발생 + +응답은 200이고 지연만 올라가는 것이 이 시나리오의 핵심입니다. + +```bash +if (( REVISION_OK )); then + python3 scripts/loadgen.py \ + "https://${APP_FQDN}/api/orders" \ + --requests 90 \ + --concurrency 8 \ + --expect-status 200 \ + --timeout 15 \ + --output "${EVIDENCE_DIR}/load.json" +else + echo "지연이 적용되지 않아 부하를 넣지 않습니다. 4단계 복구로 넘어가세요." >&2 +fi +``` + +### 3. 경고 확인 + +```bash +ALERT_ID="" +ALERT_FIRED_AT="" +if (( REVISION_OK )); then + DEADLINE=$(( SECONDS + 720 )) + while (( SECONDS < DEADLINE )); do + ALERT_JSON="$(az rest \ + --method get \ + --url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/providers/Microsoft.AlertsManagement/alerts?api-version=2019-03-01&targetResourceGroup=${RESOURCE_GROUP}&monitorCondition=Fired" \ + --query "value[?contains(properties.essentials.alertRule, '${ALERT_RULE_NAME}')] | [0].{id:id, started:properties.essentials.startDateTime}" \ + -o json)" + ALERT_ID="$(jq -r '.id // empty' <<<"${ALERT_JSON}")" + ALERT_FIRED_AT="$(jq -r '.started // empty' <<<"${ALERT_JSON}")" + [[ -n "${ALERT_ID}" ]] && break + sleep 20 + done +fi +echo "${ALERT_ID:-경고가 아직 없습니다}" +``` + +경고가 끝내 발생하지 않아도 4단계 복구는 실행합니다. + +### 4. 복구 + +```bash +RECOVERY_OK=0 +if (( INJECTED )); then +RECOVERY_OLD_REVISION="$(az containerapp show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query properties.latestRevisionName -o tsv)" + +az containerapp update \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --set-env-vars ORDER_DELAY_MS=0 \ + --output none + +REVISION_OK=0 +DEADLINE=$(( SECONDS + 600 )) +while (( SECONDS < DEADLINE )); do + NEW_REVISION="$(az containerapp show \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query properties.latestRevisionName -o tsv)" + STATE="$(az containerapp revision list \ + --resource-group "${RESOURCE_GROUP}" \ + --name "${APP_NAME}" \ + --query "[?name=='${NEW_REVISION}'].{health:properties.healthState, active:properties.active} | [0]" -o json)" + echo "${NEW_REVISION} ${STATE}" + if [[ -n "${NEW_REVISION}" && "${NEW_REVISION}" != "${RECOVERY_OLD_REVISION}" ]] \ + && [[ "$(jq -r '.health // empty' <<<"${STATE}")" == "Healthy" ]] \ + && [[ "$(jq -r '.active // empty' <<<"${STATE}")" == "true" ]]; then + REVISION_OK=1 + break + fi + sleep 10 +done +(( REVISION_OK )) || echo "새 revision이 시간 안에 정상이 되지 않았습니다." >&2 +RECOVERY_OK=${REVISION_OK} +RECOVERED_AT="" +(( RECOVERY_OK )) && RECOVERED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +curl -s --max-time 15 -o /dev/null -w '%{time_total}s %{http_code}\n' "https://${APP_FQDN}/api/orders" +else + echo "주입된 장애가 없어 복구를 건너뜁니다." >&2 + RECOVERY_OK=1 +fi +``` + +```bash +ALERT_CONDITION="" +if [[ -n "${ALERT_ID}" ]]; then + DEADLINE=$(( SECONDS + 1500 )) + while (( SECONDS < DEADLINE )); do + ALERT_CONDITION="$(az rest \ + --method get \ + --url "https://management.azure.com${ALERT_ID}?api-version=2019-03-01" \ + --query "properties.essentials.monitorCondition" -o tsv)" + echo "${ALERT_CONDITION}" + [[ "${ALERT_CONDITION}" == "Resolved" ]] && break + sleep 20 + done +fi +``` + +### 5. 실행 결과 기록 + +```bash +if (( LAB_READY )); then +ALERT_RESOLVED_AT="" +[[ "${ALERT_CONDITION}" == "Resolved" ]] && ALERT_RESOLVED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +jq -n \ + --arg scenario s2 \ + --arg injectedAt "${INJECTED_AT}" \ + --arg alertRule "${ALERT_RULE_NAME}" \ + --arg alertId "${ALERT_ID}" \ + --arg alertFiredAt "${ALERT_FIRED_AT}" \ + --arg recoveredAt "${RECOVERED_AT}" \ + --arg alertResolvedAt "${ALERT_RESOLVED_AT}" \ + '{scenario: $scenario, injected_at: $injectedAt, alert_rule: $alertRule, + alert_id: $alertId, alert_fired_at: $alertFiredAt, recovered_at: (if $recoveredAt == "" then null else $recoveredAt end), + alert_resolved_at: (if $alertResolvedAt == "" then null else $alertResolvedAt end)}' \ + > "${EVIDENCE_DIR}/timeline.json" + +if (( RECOVERY_OK )) && [[ "${ALERT_CONDITION}" == "Resolved" ]]; then + python3 scripts/lab_state.py mark-recovered s2 "${EVIDENCE_DIR}" +else + python3 scripts/lab_state.py mark-failed s2 "${EVIDENCE_DIR}" \ + --reason "recovery_ok=${RECOVERY_OK} alert=${ALERT_CONDITION:-unknown}" +fi +fi +``` + +복구는 워크로드 정상화(`RECOVERY_OK`)와 경고 해제가 모두 확인될 때만 인정합니다. 둘 중 하나라도 어긋나면 실패로 기록되므로, 장애가 남아 있는 실행이 성공으로 채점되지 않습니다. + +### 6. 조사 근거 수집 + +```bash +AGENT_ENDPOINT="$(jq -r '.agent_endpoint // empty' evidence/agent-setup.json)" +case "${AGENT_ENDPOINT}" in + https://*"<"* | https://*">"* | "https://") AGENT_ENDPOINT="" ;; + https://*) ;; + *) AGENT_ENDPOINT="" ;; +esac + +if [[ -z "${ALERT_ID}" || -z "${AGENT_ENDPOINT}" ]]; then + echo "ALERT_ID가 없거나 agent_endpoint가 https URL이 아니어서 캡처를 건너뜁니다." >&2 +else + app/.venv/bin/python scripts/capture_agent.py \ + --scenario s2 \ + --alert-id "${ALERT_ID}" \ + --endpoint "${AGENT_ENDPOINT}" \ + --output-dir "${EVIDENCE_DIR}" \ + --timeout 1200 \ + --interval 15 || true + + if [[ -f "${EVIDENCE_DIR}/normalized-timeline.json" ]]; then + python3 scripts/lab_state.py record-capture s2 \ + --timeline "${EVIDENCE_DIR}/normalized-timeline.json" \ + --evidence-dir "${EVIDENCE_DIR}" + + app/.venv/bin/python scripts/render_capture.py \ + "${EVIDENCE_DIR}/normalized-timeline.json" \ + assets/captures/s2 \ + --scenario s2 + else + echo "정규화된 타임라인이 없어 캡처를 기록하지 않았습니다." >&2 + fi +fi +``` + +`agent_endpoint`는 `https://`로 시작하고 자리표시자 괄호가 없어야 합니다. `http://`를 쓰면 데이터 평면 토큰이 평문으로 나갑니다. `capture_agent.py`는 제한 시간까지 결론을 받지 못하면 종료 코드 3으로 끝나며, 이는 "결론 없음"을 그대로 기록하는 정상 경로입니다. 결과 기록을 렌더링보다 먼저 하는 이유는 이미지 생성이 실패해도 관측한 결과를 잃지 않기 위해서입니다. + +## 지름길 + +```bash ./scripts/lab.sh run s2 ./scripts/lab.sh capture s2 ``` +같은 명령을 같은 순서로 실행하면서 종료 시 자동 복구까지 처리합니다. 순서 게이트는 수동 실행도 `begin-run`을 통해 동일하게 적용받으며, 차이는 실패 시 복구를 누가 하느냐입니다. + ## Azure에서 발생하는 변화 | 순서 | 변화 | diff --git a/monitor/sre-agent-event-lab/guides/04-scenario-s3.md b/monitor/sre-agent-event-lab/guides/04-scenario-s3.md index 95cbed3e..09f852a4 100644 --- a/monitor/sre-agent-event-lab/guides/04-scenario-s3.md +++ b/monitor/sre-agent-event-lab/guides/04-scenario-s3.md @@ -9,14 +9,242 @@ - 다른 시나리오의 실행이 `running`이나 `failed`로 남아 있지 않습니다. S1을 다시 돌리다 실패한 채로 두면 S2 기록이 멀쩡해도 S3는 거부됩니다. - 역할 할당을 만들고 지울 권한이 그대로 있습니다. -## 실행 명령 +## 수동 실행 + +이 시나리오만 환경 변수가 아니라 RBAC를 건드립니다. 삭제·복구 대상은 출력에 기록된 Blob 컨테이너 범위의 단일 역할 하나뿐입니다. ```bash cd monitor/sre-agent-event-lab +LAB_READY=1 + +RESOURCE_GROUP="$(azd env get-value AZURE_RESOURCE_GROUP 2>/dev/null)" || LAB_READY=0 +SUBSCRIPTION_ID="$(azd env get-value AZURE_SUBSCRIPTION_ID 2>/dev/null)" || LAB_READY=0 +APP_FQDN="$(azd env get-value AZURE_CONTAINER_APP_FQDN 2>/dev/null)" || LAB_READY=0 +WORKLOAD_PRINCIPAL_ID="$(azd env get-value AZURE_CONTAINER_APP_PRINCIPAL_ID 2>/dev/null)" || LAB_READY=0 +STORAGE_CONTAINER_SCOPE="$(azd env get-value AZURE_STORAGE_CONTAINER_SCOPE 2>/dev/null)" || LAB_READY=0 +BLOB_ROLE_ASSIGNMENT_NAME="$(azd env get-value AZURE_BLOB_ROLE_ASSIGNMENT_NAME 2>/dev/null)" || LAB_READY=0 + +ALERT_RULE_NAME="alert-sre-lab-s3-storage-rbac" +EVIDENCE_DIR="${PWD}/evidence/s3-$(date -u +%Y%m%dT%H%M%SZ)" + +for value in "${RESOURCE_GROUP}" "${SUBSCRIPTION_ID}" "${APP_FQDN}" "${WORKLOAD_PRINCIPAL_ID}" "${STORAGE_CONTAINER_SCOPE}" "${BLOB_ROLE_ASSIGNMENT_NAME}"; do + [[ -n "${value// /}" ]] || LAB_READY=0 +done + +if (( LAB_READY )); then + mkdir -p "${EVIDENCE_DIR}" + python3 scripts/lab_state.py begin-run s3 "${EVIDENCE_DIR}" || LAB_READY=0 +else + echo "azd 환경 값을 읽지 못했습니다. 먼저 azd provision을 실행하세요." >&2 +fi +(( LAB_READY )) || echo "준비되지 않았습니다. 아래 단계는 모두 건너뜁니다." >&2 +``` + +`azd env get-value`는 실패해도 오류 문장을 표준 출력으로 내보내므로 위 검사가 필요합니다. `begin-run`은 이번 시도를 기록하면서 순서·중복 실행 게이트도 함께 적용하고, 거부되면 `LAB_READY`가 0이 되어 이후 단계가 모두 건너뜁니다. 근거 디렉터리를 절대 경로로 두는 이유는 채점기와 캡처 도구가 기록된 경로를 실행 위치와 무관하게 다시 열기 때문입니다. + +복구에 필요한 세 값이 비어 있으면 권한을 지운 뒤 되돌릴 수 없고, 빈 범위로 역할을 만들면 구독 전체에 권한이 부여됩니다. 그래서 위 검사를 통과하지 못하면 삭제 자체가 실행되지 않습니다. + +### 1. 권한 삭제 + +```bash +INJECTED=0 +if (( LAB_READY )); then + ROLE_ASSIGNMENT_ID="${STORAGE_CONTAINER_SCOPE}/providers/Microsoft.Authorization/roleAssignments/${BLOB_ROLE_ASSIGNMENT_NAME}" + INJECTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + ROLE_DELETED_AT="" + az role assignment delete --ids "${ROLE_ASSIGNMENT_ID}" \ + && INJECTED=1 && ROLE_DELETED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +fi +``` + +### 2. 전파 대기 + +데이터 평면에 반영될 때까지 몇 분 걸립니다. 503이 나올 때까지 최대 5분 기다립니다. + +```bash +PROPAGATED=0 +if (( INJECTED )); then + DEADLINE=$(( SECONDS + 300 )) + while (( SECONDS < DEADLINE )); do + STATUS="$(curl -s --max-time 15 -o /dev/null -w '%{http_code}' "https://${APP_FQDN}/api/documents")" + echo "${STATUS}" + if [[ "${STATUS}" == "503" ]]; then + PROPAGATED=1 + break + fi + sleep 10 + done +fi +``` + +### 3. 부하 발생 + +```bash +if (( PROPAGATED )); then + python3 scripts/loadgen.py \ + "https://${APP_FQDN}/api/documents" \ + --requests 60 \ + --concurrency 4 \ + --expect-status 503 \ + --output "${EVIDENCE_DIR}/load.json" +else + echo "503이 확인되지 않아 부하를 넣지 않습니다. 5단계 복구로 넘어가세요." >&2 +fi +``` + +### 4. 경고 확인 + +```bash +ALERT_ID="" +ALERT_FIRED_AT="" +if (( PROPAGATED )); then + DEADLINE=$(( SECONDS + 720 )) + while (( SECONDS < DEADLINE )); do + ALERT_JSON="$(az rest \ + --method get \ + --url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/providers/Microsoft.AlertsManagement/alerts?api-version=2019-03-01&targetResourceGroup=${RESOURCE_GROUP}&monitorCondition=Fired" \ + --query "value[?contains(properties.essentials.alertRule, '${ALERT_RULE_NAME}')] | [0].{id:id, started:properties.essentials.startDateTime}" \ + -o json)" + ALERT_ID="$(jq -r '.id // empty' <<<"${ALERT_JSON}")" + ALERT_FIRED_AT="$(jq -r '.started // empty' <<<"${ALERT_JSON}")" + [[ -n "${ALERT_ID}" ]] && break + sleep 20 + done +fi +echo "${ALERT_ID:-경고가 아직 없습니다}" +``` + +경고가 발생하지 않아도 5단계 복구는 반드시 실행합니다. + +### 5. 권한 복구 + +같은 이름·같은 범위로 최소 권한만 되돌립니다. 이미 존재하면 다시 만들지 않습니다. + +```bash +RECOVERY_OK=0 +RECOVERED_AT="" +if (( INJECTED )); then + EXISTING="$(az role assignment list \ + --scope "${STORAGE_CONTAINER_SCOPE}" \ + --assignee-object-id "${WORKLOAD_PRINCIPAL_ID}" \ + --query "[?roleDefinitionName=='Storage Blob Data Reader'].id | [0]" -o tsv)" + + if [[ -n "${EXISTING}" ]]; then + RECOVERY_OK=1 + else + az role assignment create \ + --name "${BLOB_ROLE_ASSIGNMENT_NAME}" \ + --assignee-object-id "${WORKLOAD_PRINCIPAL_ID}" \ + --assignee-principal-type ServicePrincipal \ + --role "Storage Blob Data Reader" \ + --scope "${STORAGE_CONTAINER_SCOPE}" \ + --output none && RECOVERY_OK=1 + fi + (( RECOVERY_OK )) && RECOVERED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + curl -s --max-time 15 -o /dev/null -w '%{http_code}\n' "https://${APP_FQDN}/api/documents" +else + echo "삭제된 권한이 없어 복구를 건너뜁니다." >&2 + RECOVERY_OK=1 +fi +``` + +`RECOVERY_OK`가 0이면 워크로드에 Blob 권한이 없는 상태입니다. 값을 확인해 같은 범위로 직접 다시 만들어야 합니다. + +```bash +ALERT_CONDITION="" +if [[ -n "${ALERT_ID}" ]]; then + DEADLINE=$(( SECONDS + 1500 )) + while (( SECONDS < DEADLINE )); do + ALERT_CONDITION="$(az rest \ + --method get \ + --url "https://management.azure.com${ALERT_ID}?api-version=2019-03-01" \ + --query "properties.essentials.monitorCondition" -o tsv)" + echo "${ALERT_CONDITION}" + [[ "${ALERT_CONDITION}" == "Resolved" ]] && break + sleep 20 + done +fi +``` + +### 6. 실행 결과 기록 + +```bash +if (( LAB_READY )); then +ALERT_RESOLVED_AT="" +[[ "${ALERT_CONDITION}" == "Resolved" ]] && ALERT_RESOLVED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +jq -n \ + --arg scenario s3 \ + --arg injectedAt "${INJECTED_AT}" \ + --arg roleDeletedAt "${ROLE_DELETED_AT}" \ + --arg alertRule "${ALERT_RULE_NAME}" \ + --arg alertId "${ALERT_ID}" \ + --arg alertFiredAt "${ALERT_FIRED_AT}" \ + --arg recoveredAt "${RECOVERED_AT}" \ + --arg alertResolvedAt "${ALERT_RESOLVED_AT}" \ + '{scenario: $scenario, injected_at: $injectedAt, role_deleted_at: (if $roleDeletedAt == "" then null else $roleDeletedAt end), alert_rule: $alertRule, + alert_id: $alertId, alert_fired_at: $alertFiredAt, recovered_at: (if $recoveredAt == "" then null else $recoveredAt end), + alert_resolved_at: (if $alertResolvedAt == "" then null else $alertResolvedAt end)}' \ + > "${EVIDENCE_DIR}/timeline.json" + +if (( RECOVERY_OK )) && [[ "${ALERT_CONDITION}" == "Resolved" ]]; then + python3 scripts/lab_state.py mark-recovered s3 "${EVIDENCE_DIR}" +else + python3 scripts/lab_state.py mark-failed s3 "${EVIDENCE_DIR}" \ + --reason "recovery_ok=${RECOVERY_OK} alert=${ALERT_CONDITION:-unknown}" +fi +fi +``` + +복구는 워크로드 정상화(`RECOVERY_OK`)와 경고 해제가 모두 확인될 때만 인정합니다. 둘 중 하나라도 어긋나면 실패로 기록되므로, 장애가 남아 있는 실행이 성공으로 채점되지 않습니다. + +### 7. 조사 근거 수집 + +```bash +AGENT_ENDPOINT="$(jq -r '.agent_endpoint // empty' evidence/agent-setup.json)" +case "${AGENT_ENDPOINT}" in + https://*"<"* | https://*">"* | "https://") AGENT_ENDPOINT="" ;; + https://*) ;; + *) AGENT_ENDPOINT="" ;; +esac + +if [[ -z "${ALERT_ID}" || -z "${AGENT_ENDPOINT}" ]]; then + echo "ALERT_ID가 없거나 agent_endpoint가 https URL이 아니어서 캡처를 건너뜁니다." >&2 +else + app/.venv/bin/python scripts/capture_agent.py \ + --scenario s3 \ + --alert-id "${ALERT_ID}" \ + --endpoint "${AGENT_ENDPOINT}" \ + --output-dir "${EVIDENCE_DIR}" \ + --timeout 1200 \ + --interval 15 || true + + if [[ -f "${EVIDENCE_DIR}/normalized-timeline.json" ]]; then + python3 scripts/lab_state.py record-capture s3 \ + --timeline "${EVIDENCE_DIR}/normalized-timeline.json" \ + --evidence-dir "${EVIDENCE_DIR}" + + app/.venv/bin/python scripts/render_capture.py \ + "${EVIDENCE_DIR}/normalized-timeline.json" \ + assets/captures/s3 \ + --scenario s3 + else + echo "정규화된 타임라인이 없어 캡처를 기록하지 않았습니다." >&2 + fi +fi +``` + +`agent_endpoint`는 `https://`로 시작하고 자리표시자 괄호가 없어야 합니다. `http://`를 쓰면 데이터 평면 토큰이 평문으로 나갑니다. `capture_agent.py`는 제한 시간까지 결론을 받지 못하면 종료 코드 3으로 끝나며, 이는 "결론 없음"을 그대로 기록하는 정상 경로입니다. 결과 기록을 렌더링보다 먼저 하는 이유는 이미지 생성이 실패해도 관측한 결과를 잃지 않기 위해서입니다. + +## 지름길 + +```bash ./scripts/lab.sh run s3 ./scripts/lab.sh capture s3 ``` +`run`은 위 순서에 더해 필요한 배포 출력이 비어 있지 않은지 삭제 전에 확인하고, 전파 대기를 최대 5분으로 제한하며, 종료 시 역할 복구를 보장합니다. 수동 실행에서는 이 확인들이 없으므로 삭제 후 복구 명령까지 반드시 직접 완료해야 합니다. + ## Azure에서 발생하는 변화 | 순서 | 변화 | diff --git a/monitor/sre-agent-event-lab/scripts/tests/test_lab_guides.py b/monitor/sre-agent-event-lab/scripts/tests/test_lab_guides.py index 369f8000..e02006ba 100644 --- a/monitor/sre-agent-event-lab/scripts/tests/test_lab_guides.py +++ b/monitor/sre-agent-event-lab/scripts/tests/test_lab_guides.py @@ -467,6 +467,66 @@ def test_s3_guide_documents_propagation_wait_and_manual_restore_command(): assert "az role assignment create" in text +MANUAL_SCENARIO_COMMANDS = { + "02-scenario-s1.md": ( + "az containerapp update", + "--set-env-vars FAILURE_MODE=http500", + "--set-env-vars FAILURE_MODE=none", + ), + "03-scenario-s2.md": ( + "az containerapp update", + "--set-env-vars ORDER_DELAY_MS=4000", + "--set-env-vars ORDER_DELAY_MS=0", + ), + "04-scenario-s3.md": ( + "az role assignment delete --ids", + "az role assignment create", + ), +} + + +def test_scenario_guides_lead_with_the_real_azure_commands(): + """`lab.sh run sX` hides the operation it performs, which is the part + worth learning. Each scenario guide states the injection and the revert + as commands the operator runs, so the lab teaches the Azure change + instead of a wrapper.""" + for name, commands in MANUAL_SCENARIO_COMMANDS.items(): + text = (GUIDES / name).read_text() + manual_index = text.index("## 수동 실행") + shortcut_index = text.index("## 지름길") + assert manual_index < shortcut_index, name + manual_section = text[manual_index:shortcut_index] + for command in commands: + assert command in manual_section, (name, command) + + +def test_scenario_guides_keep_every_manual_step_runnable_without_lab_sh(): + """A manual run still has to produce the same evidence and recorded + state the scorer reads, or the manual path dead-ends at scoring.""" + for name, scenario in SCENARIO_GUIDES.items(): + text = (GUIDES / name).read_text() + manual_section = text[text.index("## 수동 실행"):text.index("## 지름길")] + assert "scripts/loadgen.py" in manual_section, name + assert "az rest" in manual_section, name + assert ( + "scripts/lab_state.py mark-recovered {0}".format(scenario) + in manual_section + ), name + assert "scripts/capture_agent.py" in manual_section, name + assert ( + "scripts/lab_state.py record-capture {0}".format(scenario) + in manual_section + ), name + + +def test_scenario_guides_present_lab_sh_as_an_optional_shortcut(): + for name, scenario in SCENARIO_GUIDES.items(): + text = (GUIDES / name).read_text() + shortcut_section = text[text.index("## 지름길"):] + assert "./scripts/lab.sh run {0}".format(scenario) in shortcut_section, name + assert "./scripts/lab.sh capture {0}".format(scenario) in shortcut_section, name + + def test_validation_results_keeps_the_one_minute_static_run_and_explains_it(): """The recorded S1/S2/S3 run used a one-minute static threshold. That result stays plausible because the later live failure was the legacy @@ -684,7 +744,8 @@ def test_every_guide_opens_with_prerequisites_and_closes_with_a_next_step(): def test_scenario_guides_use_the_required_section_order(): required = [ "## 시작 조건", - "## 실행 명령", + "## 수동 실행", + "## 지름길", "## Azure에서 발생하는 변화", "## SRE Agent에서 확인할 항목", "## 성공·부분 성공·실패 판정",