diff --git a/.devcontainer/sre-agent-event-lab/devcontainer.json b/.devcontainer/sre-agent-event-lab/devcontainer.json new file mode 100644 index 00000000..afb0b519 --- /dev/null +++ b/.devcontainer/sre-agent-event-lab/devcontainer.json @@ -0,0 +1,35 @@ +{ + "name": "Azure SRE Agent Event Lab", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "ghcr.io/devcontainers/features/azure-cli:1": { + "version": "latest", + "extensions": "log-analytics,containerapp" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "3.12", + "installTools": true, + "toolsToInstall": "uv" + }, + "ghcr.io/azure/azure-dev/azd:0": { + "version": "latest" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-azuretools.vscode-azurecontainerapps", + "ms-azuretools.vscode-bicep", + "ms-python.python" + ] + } + }, + "postCreateCommand": "cd monitor/sre-agent-event-lab && ./scripts/setup-venv.sh", + "postAttachCommand": { + "next-steps": "echo 'Next: az login --use-device-code, then cd monitor/sre-agent-event-lab && source ./scripts/lab-env.sh'" + }, + "remoteUser": "vscode" +} diff --git a/monitor/sre-agent-event-lab/README.md b/monitor/sre-agent-event-lab/README.md index 68ccb643..3c4b6d8f 100644 --- a/monitor/sre-agent-event-lab/README.md +++ b/monitor/sre-agent-event-lab/README.md @@ -22,9 +22,7 @@ Azure Container Apps에 장애를 세 번 주입하고, Azure Monitor 경고를 - Storage 계정(Standard_LRS) - 1분 주기 로그 검색 경고 규칙 3개(평가 주기가 짧을수록 규칙당 단가가 올라갑니다) -Azure SRE Agent는 이 실습이 만들지 않습니다. 미리 만들어 둔 Agent를 사용하며 [별도로 과금](https://azure.microsoft.com/pricing/details/sre-agent/)됩니다. - -안전 경계는 스크립트가 강제합니다. +Azure SRE Agent는 이 실습이 만들지 않습니다. 미리 만들어 둔 Agent를 사용하며 [별도로 과금](https://azure.microsoft.com/pricing/details/sre-agent/)됩니다. 안전 경계는 스크립트가 강제합니다. - 현재 azd 환경이 가리키는 구독과 `az account show`의 활성 구독이 다르면 실행을 거부합니다. - 리소스 그룹에 `purpose=sre-agent-event-lab`과 `azd-env-name=<현재 environment>` 태그가 모두 없으면 거부합니다. @@ -32,16 +30,34 @@ Azure SRE Agent는 이 실습이 만들지 않습니다. 미리 만들어 둔 Ag - 응답 계획은 모두 `Review` 모드로 두어 Agent가 승인 없이 변경하지 못하게 합니다. - `evidence/`에는 비밀 값, 연결 문자열, 액세스 토큰을 저장하지 않습니다. +## 시작하기: fork와 Codespaces + +이 실습은 **본인 fork에서** 진행합니다. Agent에 저장소를 연결하면 조사 결과 이슈가 그 저장소에 생성되므로, 원본을 연결하면 참가자 전원의 이슈가 한곳에 쌓이고 쓰기 권한도 없습니다. + +1. 이 저장소를 본인 계정으로 fork합니다. +2. fork에서 **Code > Codespaces > New with options**를 열고 dev container로 `Azure SRE Agent Event Lab`을 고릅니다. `az`, `azd`, `gh`, Python, `uv`가 설치되고 `postCreateCommand`가 `setup-venv.sh`를 실행해 `app/.venv`까지 만듭니다. +3. 터미널에서 로그인한 뒤 환경을 한 번 읽습니다. + +```bash +az login --use-device-code +azd auth login + +source ./monitor/sre-agent-event-lab/scripts/lab-env.sh +``` + +`lab-env.sh`는 `azd`가 게시한 배포 출력만 읽어 리소스 그룹·구독·Container App·Storage 범위 등을 현재 셸에 export하고, 값이 하나라도 없으면 `LAB_READY=0`으로 알려 줍니다. 이후 가이드의 명령은 이 값들을 그대로 사용하므로 단계마다 다시 조회하지 않습니다. 비밀 값은 읽지도 출력하지도 않습니다. + +로컬에서 진행한다면 아래 사전 조건을 직접 갖춘 뒤 같은 `source` 한 줄로 시작합니다. Codespaces에서는 dev container가 이미 갖춰 줍니다. + ## 사전 조건 -- `az`, `azd`, `jq`, `curl`, `python3` +- `az`, `azd`, `jq`, `curl`, `python3`, [`uv`](https://docs.astral.sh/uv/getting-started/installation/) — `uv`는 `app/.venv`를 만드는 `scripts/setup-venv.sh`가 쓰는 유일한 도구이며, 사내 프록시로 구성된 인덱스 설정을 그대로 씁니다(공개 PyPI `pip` 폴백 없음). - `az extension add --name log-analytics` (`az monitor log-analytics query` 제공) - `az login`과 `azd auth login` — 두 CLI는 자격 증명을 따로 관리합니다. - 구독 Contributor, 역할 할당을 위한 Owner 또는 User Access Administrator - Azure SRE Agent를 만들 수 있는 [지원 지역](https://learn.microsoft.com/azure/sre-agent/supported-regions) 접근 권한 - 브라우저에서 `https://sre.azure.com` 및 `*.azuresre.ai` 접근 -- Agent에 연결할 GitHub 저장소 권한 -- [`uv`](https://docs.astral.sh/uv/getting-started/installation/) — `app/.venv`를 만드는 `scripts/setup-venv.sh`가 이 도구만 사용하며, 사내 프록시로 구성된 `uv`의 인덱스 설정을 그대로 씁니다. 공개 PyPI로 우회하는 `pip` 폴백은 없습니다. +- Agent에 연결할 GitHub 저장소 권한 — 본인 fork처럼 이슈를 만들 수 있는 저장소여야 합니다 이 실습의 모든 명령은 아래에서 한 번만 진입하는 이 디렉터리를 기준으로 합니다. @@ -49,12 +65,11 @@ Azure SRE Agent는 이 실습이 만들지 않습니다. 미리 만들어 둔 Ag cd monitor/sre-agent-event-lab ``` -로컬 검증만 먼저 해 보려면 다음을 실행합니다. +로컬 검증만 먼저 해 보려면 다음을 실행합니다. Codespaces에서는 `setup-venv.sh`가 이미 실행된 상태입니다. ```bash ./scripts/setup-venv.sh app/.venv/bin/python -m pytest app -q - bash -n scripts/*.sh az bicep build --file infra/main.bicep --stdout >/dev/null ``` @@ -65,9 +80,7 @@ az bicep build --file infra/main.bicep --stdout >/dev/null azd env new sre-event-lab --location koreacentral ``` -`--subscription`을 생략하면 azd가 로그인된 계정의 구독 목록에서 고르게 합니다. 특정 구독을 고정하려면 `--subscription `를 붙이세요. 리소스 그룹 이름은 지정하지 않으면 `rg-`이 됩니다. - -`.env.example`은 스크립트가 읽는 설정 이름과 허용 기본값만 적어 둔 참고 파일입니다. 값을 바꾸려면 `azd env set `로 현재 azd 환경에 저장합니다. +`--subscription`을 생략하면 azd가 로그인된 계정의 구독 목록에서 고르게 합니다. 특정 구독을 고정하려면 `--subscription `를 붙이세요. 리소스 그룹 이름은 지정하지 않으면 `rg-`이 됩니다. `.env.example`은 스크립트가 읽는 설정 이름과 허용 기본값만 적어 둔 참고 파일이며, 값을 바꾸려면 `azd env set `로 현재 azd 환경에 저장합니다. ## 배포 diff --git a/monitor/sre-agent-event-lab/guides/01-agent-setup.md b/monitor/sre-agent-event-lab/guides/01-agent-setup.md index 66670718..4a7ab138 100644 --- a/monitor/sre-agent-event-lab/guides/01-agent-setup.md +++ b/monitor/sre-agent-event-lab/guides/01-agent-setup.md @@ -4,6 +4,7 @@ ## 시작 조건 +- **이 저장소를 본인 계정으로 fork했습니다.** Agent에 연결하는 저장소는 fork여야 합니다. 아래 "연결할 원본"에서 설명하듯 조사 결과 이슈가 연결된 저장소에 생성되므로, 원본 저장소를 연결하면 참가자 전원의 이슈가 한곳에 쌓입니다. - [README](../README.md)의 `azd up`이 성공했고 `/healthz`가 HTTP 200을 반환합니다. - `https://sre.azure.com`에 로그인할 수 있고, 대상 구독에 Azure SRE Agent가 **Running** 상태로 하나 있습니다. - 역할을 만들 수 있는 권한(Owner 또는 User Access Administrator)이 있습니다. @@ -11,10 +12,11 @@ ```bash cd monitor/sre-agent-event-lab -azd env get-value AZURE_SUBSCRIPTION_ID -azd env get-value AZURE_RESOURCE_GROUP +source ./scripts/lab-env.sh ``` +`lab-env.sh`는 구독 ID, 리소스 그룹, 그리고 `origin` 원격에서 유추한 저장소 URL을 출력합니다. fork를 열었는지까지 확인하지는 않으므로, `Repository` 줄이 **본인 계정의 저장소**인지 직접 확인하세요. 원본 저장소가 찍혀 있다면 fork를 clone해 다시 시작합니다. + ## 설정 페이지 열기 Agent를 열면 위쪽 상태 표시줄이 아직 연결하지 않은 데이터 원본 수를 보여 줍니다. 오른쪽 **Complete setup**을 누르면 설정 페이지로 이동합니다. @@ -33,13 +35,15 @@ Agent를 열면 위쪽 상태 표시줄이 아직 연결하지 않은 데이터 | 원본 | 이 실습에서 넣는 값 | 왜 필요한가 | |---|---|---| -| Code | 이 저장소(`hellices/devguidesample`)와 브랜치 | 조사 결론이 코드와 최근 변경을 짚게 합니다 | -| Azure resources | `azd env get-value AZURE_RESOURCE_GROUP`이 출력한 리소스 그룹 | 메트릭·리소스 상태·Activity Log를 읽습니다 | +| Code | **본인이 fork한 저장소**와 브랜치 (`source ./scripts/lab-env.sh`가 출력한 `Repository` 값) | 조사 결론이 코드와 최근 변경을 짚게 합니다 | +| Azure resources | `lab-env.sh`가 출력한 `Resource group` 값 | 메트릭·리소스 상태·Activity Log를 읽습니다 | | Knowledge files | [runbooks/incident-response.md](../runbooks/incident-response.md) | 조사 순서와 금지 사항을 팀 규칙으로 강제합니다 | | Incidents | Azure Monitor | 경고를 자동으로 받아 조사 스레드를 엽니다 | 저장소는 [Connect source code](https://learn.microsoft.com/azure/sre-agent/connect-source-code)로 연결합니다. 이슈·PR 조작까지 맡기려면 [GitHub connector](https://learn.microsoft.com/azure/sre-agent/setup-github-connector)를 추가로 설정하되, 토큰 값은 포털 입력창에만 넣고 이 저장소의 어떤 파일에도 남기지 않습니다. +**연결 대상은 반드시 본인 fork여야 합니다.** GitHub connector를 붙이면 Agent가 조사 결과를 **이슈로 생성**하는데, 그 이슈는 연결된 저장소에 만들어집니다. 원본 저장소를 연결하면 본인 실습의 장애 이슈가 원본에 쌓이고, 원본에 쓰기 권한이 없으면 연결 자체가 실패합니다. + ## 역할 부여 Agent가 실습 리소스를 읽고 구독의 경고를 훑을 수 있어야 합니다. 범위를 넓히지 마세요. @@ -134,11 +138,18 @@ UAMI_MONITORING_CONTRIBUTOR_ASSIGNMENT_ID="$(az role assignment create \ ```bash azd env set SRE_AGENT_NAME "<포털에 보이는 Agent 이름>" azd env set SRE_AGENT_RESOURCE_ID "" -azd env set SRE_REPOSITORY_URL "https://github.com//" azd env set SRE_REPOSITORY_BRANCH "main" azd env set SRE_KNOWLEDGE_PATH "runbooks/incident-response.md" + +if [[ -n "${SRE_REPOSITORY_URL}" ]]; then + azd env set SRE_REPOSITORY_URL "${SRE_REPOSITORY_URL}" +else + echo "저장소 URL을 직접 지정하세요: azd env set SRE_REPOSITORY_URL \"https://github.com/<본인 계정>/<저장소>\"" >&2 +fi ``` +`SRE_REPOSITORY_URL`은 `source ./scripts/lab-env.sh`가 `origin`에서 유추한 값입니다. 원격 URL에 자격 증명이 들어 있으면 `lab-env.sh`가 값을 비워 두므로, 위 분기가 직접 지정하라고 알려 줍니다. 어느 경우든 **본인이 이슈를 만들 수 있는 저장소**여야 합니다. + 토큰, 연결 문자열, 클라이언트 비밀은 azd 환경에도 저장하지 않습니다. 인증은 Agent의 관리 ID와 포털 OAuth 흐름이 처리합니다. ## 근거 파일 만들기 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 1a04f020..54501c75 100644 --- a/monitor/sre-agent-event-lab/guides/02-scenario-s1.md +++ b/monitor/sre-agent-event-lab/guides/02-scenario-s1.md @@ -15,34 +15,29 @@ 이 절의 명령을 순서대로 실행하면 스크립트 없이 시나리오 하나를 끝까지 진행할 수 있습니다. 무엇이 Azure에 적용되는지 명령 그대로 보이는 것이 목적입니다. 각 블록은 앞 단계가 성공했는지 플래그로 확인하고 진행합니다. -먼저 이번 실행에서 계속 쓸 값을 셸 변수로 읽습니다. 아래 블록부터 마지막 `record-capture`까지는 같은 셸에서 실행합니다. +Codespaces에서 이 저장소를 열었다면 `az login`을 마친 뒤 아래 한 줄로 이번 실습에 필요한 값이 모두 셸에 준비됩니다. 여기서부터 마지막 `record-capture`까지는 같은 셸에서 실행합니다. ```bash cd monitor/sre-agent-event-lab -LAB_READY=1 +source ./scripts/lab-env.sh +``` + +`lab-env.sh`는 `azd`가 게시한 배포 출력만 읽어 `RESOURCE_GROUP`, `SUBSCRIPTION_ID`, `APP_NAME`, `APP_FQDN`, `WORKLOAD_PRINCIPAL_ID`, `STORAGE_CONTAINER_SCOPE`, `BLOB_ROLE_ASSIGNMENT_NAME`을 export하고, 하나라도 읽지 못하면 `LAB_READY=0`으로 알려 줍니다. 비밀 값은 읽지도 출력하지도 않습니다. -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 +이번 시나리오에서만 쓰는 값을 정하고 시도를 기록합니다. +```bash 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 ``` -`azd env get-value`는 실패해도 오류 문장을 표준 출력으로 내보내므로 위 검사가 필요합니다. `begin-run`은 이번 시도를 기록하면서 순서·중복 실행 게이트도 함께 적용하고, 거부되면 `LAB_READY`가 0이 되어 이후 단계가 모두 건너뜁니다. 근거 디렉터리를 절대 경로로 두는 이유는 채점기와 캡처 도구가 기록된 경로를 실행 위치와 무관하게 다시 열기 때문입니다. +`begin-run`은 이번 시도를 기록하면서 순서·중복 실행 게이트도 함께 적용하고, 거부되면 `LAB_READY`가 0이 되어 이후 단계가 모두 건너뜁니다. 근거 디렉터리를 절대 경로로 두는 이유는 채점기와 캡처 도구가 기록된 경로를 실행 위치와 무관하게 다시 열기 때문입니다. ### 1. 장애 주입 @@ -58,6 +53,7 @@ if (( LAB_READY )); then --query properties.latestRevisionName -o tsv)" az containerapp update \ + --subscription "${SUBSCRIPTION_ID}" \ --resource-group "${RESOURCE_GROUP}" \ --name "${APP_NAME}" \ --set-env-vars FAILURE_MODE=http500 \ @@ -150,6 +146,7 @@ RECOVERY_OLD_REVISION="$(az containerapp show \ --query properties.latestRevisionName -o tsv)" az containerapp update \ + --subscription "${SUBSCRIPTION_ID}" \ --resource-group "${RESOURCE_GROUP}" \ --name "${APP_NAME}" \ --set-env-vars FAILURE_MODE=none \ 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 0665ae2c..b30045c4 100644 --- a/monitor/sre-agent-event-lab/guides/03-scenario-s2.md +++ b/monitor/sre-agent-event-lab/guides/03-scenario-s2.md @@ -11,34 +11,31 @@ ## 수동 실행 -S1과 같은 구조이며, 바뀌는 것은 주입 값과 부하 조건뿐입니다. 아래 블록부터 마지막 `record-capture`까지 같은 셸에서 실행합니다. +S1과 같은 구조이며, 바뀌는 것은 주입 값과 부하 조건뿐입니다. + +Codespaces에서 이 저장소를 열었다면 `az login`을 마친 뒤 아래 한 줄로 이번 실습에 필요한 값이 모두 셸에 준비됩니다. 여기서부터 마지막 `record-capture`까지는 같은 셸에서 실행합니다. ```bash cd monitor/sre-agent-event-lab -LAB_READY=1 +source ./scripts/lab-env.sh +``` -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 +`lab-env.sh`는 `azd`가 게시한 배포 출력만 읽어 `RESOURCE_GROUP`, `SUBSCRIPTION_ID`, `APP_NAME`, `APP_FQDN`, `WORKLOAD_PRINCIPAL_ID`, `STORAGE_CONTAINER_SCOPE`, `BLOB_ROLE_ASSIGNMENT_NAME`을 export하고, 하나라도 읽지 못하면 `LAB_READY=0`으로 알려 줍니다. 비밀 값은 읽지도 출력하지도 않습니다. +이번 시나리오에서만 쓰는 값을 정하고 시도를 기록합니다. + +```bash 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이 되어 이후 단계가 모두 건너뜁니다. 근거 디렉터리를 절대 경로로 두는 이유는 채점기와 캡처 도구가 기록된 경로를 실행 위치와 무관하게 다시 열기 때문입니다. +`begin-run`은 이번 시도를 기록하면서 순서·중복 실행 게이트도 함께 적용하고, 거부되면 `LAB_READY`가 0이 되어 이후 단계가 모두 건너뜁니다. ### 1. 지연 주입 @@ -52,6 +49,7 @@ if (( LAB_READY )); then --query properties.latestRevisionName -o tsv)" az containerapp update \ + --subscription "${SUBSCRIPTION_ID}" \ --resource-group "${RESOURCE_GROUP}" \ --name "${APP_NAME}" \ --set-env-vars ORDER_DELAY_MS=4000 \ @@ -141,6 +139,7 @@ RECOVERY_OLD_REVISION="$(az containerapp show \ --query properties.latestRevisionName -o tsv)" az containerapp update \ + --subscription "${SUBSCRIPTION_ID}" \ --resource-group "${RESOURCE_GROUP}" \ --name "${APP_NAME}" \ --set-env-vars ORDER_DELAY_MS=0 \ 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 09f852a4..45ee6b76 100644 --- a/monitor/sre-agent-event-lab/guides/04-scenario-s3.md +++ b/monitor/sre-agent-event-lab/guides/04-scenario-s3.md @@ -13,46 +13,43 @@ 이 시나리오만 환경 변수가 아니라 RBAC를 건드립니다. 삭제·복구 대상은 출력에 기록된 Blob 컨테이너 범위의 단일 역할 하나뿐입니다. +Codespaces에서 이 저장소를 열었다면 `az login`을 마친 뒤 아래 한 줄로 이번 실습에 필요한 값이 모두 셸에 준비됩니다. 여기서부터 마지막 `record-capture`까지는 같은 셸에서 실행합니다. + ```bash cd monitor/sre-agent-event-lab -LAB_READY=1 +source ./scripts/lab-env.sh +``` -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 +`lab-env.sh`는 `azd`가 게시한 배포 출력만 읽어 `RESOURCE_GROUP`, `SUBSCRIPTION_ID`, `APP_NAME`, `APP_FQDN`, `WORKLOAD_PRINCIPAL_ID`, `STORAGE_CONTAINER_SCOPE`, `BLOB_ROLE_ASSIGNMENT_NAME`을 export하고, 하나라도 읽지 못하면 `LAB_READY=0`으로 알려 줍니다. 비밀 값은 읽지도 출력하지도 않습니다. +이번 시나리오에서만 쓰는 값을 정하고 시도를 기록합니다. + +```bash 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이 되어 이후 단계가 모두 건너뜁니다. 근거 디렉터리를 절대 경로로 두는 이유는 채점기와 캡처 도구가 기록된 경로를 실행 위치와 무관하게 다시 열기 때문입니다. +`begin-run`은 이번 시도를 기록하면서 순서·중복 실행 게이트도 함께 적용하고, 거부되면 `LAB_READY`가 0이 되어 이후 단계가 모두 건너뜁니다. -복구에 필요한 세 값이 비어 있으면 권한을 지운 뒤 되돌릴 수 없고, 빈 범위로 역할을 만들면 구독 전체에 권한이 부여됩니다. 그래서 위 검사를 통과하지 못하면 삭제 자체가 실행되지 않습니다. +복구에 필요한 `WORKLOAD_PRINCIPAL_ID`, `STORAGE_CONTAINER_SCOPE`, `BLOB_ROLE_ASSIGNMENT_NAME`이 비어 있으면 권한을 지운 뒤 되돌릴 수 없고, 빈 범위로 역할을 만들면 구독 전체에 권한이 부여됩니다. `lab-env.sh`가 이 값들을 확인하지 못하면 `LAB_READY`가 0이 되어 삭제 자체가 실행되지 않습니다. ### 1. 권한 삭제 ```bash INJECTED=0 -if (( LAB_READY )); then +if (( LAB_READY )) \ + && [[ -n "${STORAGE_CONTAINER_SCOPE}" && -n "${BLOB_ROLE_ASSIGNMENT_NAME}" && -n "${WORKLOAD_PRINCIPAL_ID}" ]]; 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}" \ + az role assignment delete --subscription "${SUBSCRIPTION_ID}" \ + --ids "${ROLE_ASSIGNMENT_ID}" \ && INJECTED=1 && ROLE_DELETED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" fi ``` @@ -133,6 +130,7 @@ if (( INJECTED )); then RECOVERY_OK=1 else az role assignment create \ + --subscription "${SUBSCRIPTION_ID}" \ --name "${BLOB_ROLE_ASSIGNMENT_NAME}" \ --assignee-object-id "${WORKLOAD_PRINCIPAL_ID}" \ --assignee-principal-type ServicePrincipal \ diff --git a/monitor/sre-agent-event-lab/scripts/lab-env.sh b/monitor/sre-agent-event-lab/scripts/lab-env.sh new file mode 100755 index 00000000..8fd942cd --- /dev/null +++ b/monitor/sre-agent-event-lab/scripts/lab-env.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +# Resolves the lab's configuration once and exports it into the current +# shell. Source it; do not execute it: +# +# cd monitor/sre-agent-event-lab +# source ./scripts/lab-env.sh +# +# Every value here is a name, an ID or a URL that `azd provision` already +# published as a deployment output. Nothing secret is read, printed or +# written: authentication stays in the Azure CLI and azd credential stores. +# +# Why sourcing rather than a wrapper: the scenario walkthroughs are meant to +# be read and run command by command, and each command needs the same values. +# Resolving them once, in the operator's own shell, keeps every later step a +# plain `az` invocation instead of a nested substitution. +# +# `set -e` is deliberately absent. A sourced script runs in the operator's +# interactive shell, so a failed lookup here must set `LAB_READY=0` and +# explain itself -- never close the terminal that is about to show why. + +# Refuse to run as a program: exports would land in a child shell that exits +# immediately, and the operator would be left with an empty environment and +# no error. `BASH_SOURCE` is also how this file finds itself, so an empty +# value means a non-bash shell (zsh is macOS's default) where the path +# resolution below would silently point at the wrong directory. +if [[ -z "${BASH_SOURCE[0]:-}" ]]; then + echo "lab-env.sh needs bash. Start one first, then source it:" >&2 + echo " bash" >&2 + echo " source ./scripts/lab-env.sh" >&2 + return 1 2>/dev/null || exit 1 +fi + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + echo "lab-env.sh must be sourced, not executed:" >&2 + echo " source ./scripts/lab-env.sh" >&2 + exit 2 +fi + +LAB_ENV_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +LAB_ROOT="$(cd "${LAB_ENV_DIR}/.." && pwd -P)" +export LAB_ROOT + +# Refuse to resolve against a directory that is not this lab: every lookup +# below is `--cwd "${LAB_ROOT}"`, and a wrong root would bind another azd +# project's outputs -- which the S3 scenario would then delete a role from. +if [[ ! -f "${LAB_ROOT}/azure.yaml" ]]; then + echo "Not the lab directory: ${LAB_ROOT}/azure.yaml is missing." >&2 + return 1 2>/dev/null || exit 1 +fi + +LAB_READY=1 +export LAB_READY + +# lab_env_read NAME -- one azd output, or empty when azd could not answer. +# +# azd reports failure only through its exit status: it prints an `ERROR: ...` +# sentence on *stdout* (after a leading newline) and exits 1, so keeping +# stdout regardless of the status would adopt that sentence as a resource +# name. `--cwd` pins the lookup to this lab's azd project so the script works +# from any directory. +lab_env_read() { + local name="$1" + local value + if ! value="$(azd env get-value "${name}" --cwd "${LAB_ROOT}" 2>/dev/null)"; then + return 1 + fi + # A blank answer is not a value: azd prints nothing for an output the + # current environment has never had. + if [[ -z "${value//[[:space:]]/}" ]]; then + return 1 + fi + printf '%s\n' "${value}" +} + +# lab_env_bind SHELL_NAME AZD_NAME -- export one resolved value, or clear it +# and lower LAB_READY when it cannot be resolved. +lab_env_bind() { + local shell_name="$1" + local azd_name="$2" + local value + if value="$(lab_env_read "${azd_name}")"; then + printf -v "${shell_name}" '%s' "${value}" + export "${shell_name?}" + return 0 + fi + printf -v "${shell_name}" '%s' "" + export "${shell_name?}" + LAB_READY=0 + echo "Missing deployment output: ${azd_name}" >&2 + return 1 +} + +if ! command -v azd >/dev/null 2>&1; then + LAB_READY=0 + echo "azd not found on PATH. In Codespaces this comes from the devcontainer." >&2 +fi + +if ! command -v az >/dev/null 2>&1; then + LAB_READY=0 + echo "az not found on PATH. In Codespaces this comes from the devcontainer." >&2 +fi + +# The Azure CLI session is what every later command runs as. Report it as a +# prerequisite rather than letting the first `az` call fail mid-scenario. +LAB_ACTIVE_SUBSCRIPTION="" +if command -v az >/dev/null 2>&1; then + if ! LAB_ACTIVE_SUBSCRIPTION="$(az account show --query id -o tsv 2>/dev/null)"; then + LAB_READY=0 + LAB_ACTIVE_SUBSCRIPTION="" + echo "Not signed in to Azure CLI. Run: az login --use-device-code" >&2 + fi +fi + +lab_env_bind RESOURCE_GROUP AZURE_RESOURCE_GROUP || true +lab_env_bind SUBSCRIPTION_ID AZURE_SUBSCRIPTION_ID || true +lab_env_bind APP_NAME AZURE_CONTAINER_APP_NAME || true +lab_env_bind APP_FQDN AZURE_CONTAINER_APP_FQDN || true +lab_env_bind WORKLOAD_PRINCIPAL_ID AZURE_CONTAINER_APP_PRINCIPAL_ID || true +lab_env_bind STORAGE_CONTAINER_SCOPE AZURE_STORAGE_CONTAINER_SCOPE || true +lab_env_bind BLOB_ROLE_ASSIGNMENT_NAME AZURE_BLOB_ROLE_ASSIGNMENT_NAME || true + +# The manual walkthrough issues `az` commands that inherit the CLI's active +# subscription, so a mismatch would inject the failure into a same-named +# resource group somewhere else. `common.sh` refuses this for the scripted +# path; the sourced environment is where the manual path can catch it. +if [[ -n "${LAB_ACTIVE_SUBSCRIPTION}" && -n "${SUBSCRIPTION_ID}" ]]; then + if [[ "$(printf '%s' "${LAB_ACTIVE_SUBSCRIPTION}" | tr '[:upper:]' '[:lower:]')" \ + != "$(printf '%s' "${SUBSCRIPTION_ID}" | tr '[:upper:]' '[:lower:]')" ]]; then + LAB_READY=0 + echo "Active Azure CLI subscription is not the lab's." >&2 + echo " active: ${LAB_ACTIVE_SUBSCRIPTION}" >&2 + echo " lab : ${SUBSCRIPTION_ID}" >&2 + echo "Switch with: az account set --subscription ${SUBSCRIPTION_ID}" >&2 + fi +fi + +# lab_env_normalize_repo_url REMOTE -- the HTTPS form of a git remote, or +# empty when it cannot be published safely. +# +# A remote cloned behind a proxy can carry credentials +# (`https://user:@host/owner/repo`). Printing or exporting that would +# put the token in the terminal, in scrollback and in every child process, +# so a remote whose authority carries userinfo is dropped entirely rather +# than rewritten: a password may itself contain `@`, and reconstructing a +# "clean" URL from it risks keeping part of the secret. The operator is +# asked for the URL instead. SSH remotes are rewritten because the Agent's +# source connector takes an HTTPS URL. +lab_env_normalize_repo_url() { + local remote="$1" + [[ -n "${remote}" ]] || return 0 + + local url="${remote%.git}" + case "${url}" in + git@*:*) + local host="${url#git@}" + host="${host%%:*}" + local path="${url#*:}" + printf 'https://%s/%s\n' "${host}" "${path#/}" + return 0 + ;; + ssh://*) + url="https://${url#ssh://}" + ;; + esac + + case "${url}" in + *://*) + local scheme="${url%%://*}" + local rest="${url#*://}" + local authority="${rest%%/*}" + case "${authority}" in + *@*) + # `git@host` is the SSH identity, not a credential; anything else + # in the userinfo position is treated as one and refused. + if [[ "${authority%@*}" == "git" ]]; then + printf '%s://%s\n' "${scheme}" "${rest#*@}" + return 0 + fi + echo "Ignoring the git remote: it embeds credentials." >&2 + echo "Set the repository explicitly: azd env set SRE_REPOSITORY_URL \"https://github.com//\"" >&2 + return 0 + ;; + esac + printf '%s\n' "${url}" + return 0 + ;; + esac + return 0 +} + +# The repository the Agent investigates and files issues into. It must be +# the operator's own fork: the GitHub connector writes into whatever +# repository it is connected to, and a shared upstream would collect every +# participant's incidents. +SRE_REPOSITORY_URL="$(lab_env_read SRE_REPOSITORY_URL || true)" +if [[ -z "${SRE_REPOSITORY_URL}" ]] && command -v git >/dev/null 2>&1; then + # `origin` in a Codespace created from a fork is that fork, which is + # exactly the repository the Agent should be pointed at. It is only a + # suggestion: the operator confirms it when running `azd env set`. + SRE_REPOSITORY_URL="$(lab_env_normalize_repo_url \ + "$(git -C "${LAB_ROOT}" remote get-url origin 2>/dev/null || true)")" +fi +export SRE_REPOSITORY_URL + +if (( LAB_READY )); then + echo "Lab environment ready." +else + echo "Lab environment incomplete. Run 'azd provision' (or fix the errors above), then source this file again." >&2 +fi + +cat <} + Subscription : ${SUBSCRIPTION_ID:-} + Container App : ${APP_NAME:-} + Endpoint : ${APP_FQDN:-} + Repository : ${SRE_REPOSITORY_URL:-} +SUMMARY diff --git a/monitor/sre-agent-event-lab/scripts/tests/test_lab_env.py b/monitor/sre-agent-event-lab/scripts/tests/test_lab_env.py new file mode 100644 index 00000000..216ed21b --- /dev/null +++ b/monitor/sre-agent-event-lab/scripts/tests/test_lab_env.py @@ -0,0 +1,316 @@ +"""Contract tests for the one-shot lab environment and its Codespaces entry. + +An operator who opens the lab in Codespaces runs `az login` once and then +one `source`; every later command reads exported values instead of binding +each one by hand. These tests pin the pieces that promise makes: the +devcontainer Codespaces actually offers, the sourced script that resolves +and exports the values, and the guides that stopped re-binding them. +""" +import json +import os +import re +import subprocess +import textwrap +from pathlib import Path + + +REPO_ROOT = Path(__file__).parents[4] +LAB_ROOT = REPO_ROOT / "monitor" / "sre-agent-event-lab" +LAB_ENV_SH = LAB_ROOT / "scripts" / "lab-env.sh" +GUIDES = LAB_ROOT / "guides" +README = LAB_ROOT / "README.md" + +# GitHub only offers configurations stored at the repository root, either as +# `.devcontainer/devcontainer.json` or one level deep under `.devcontainer/`. +# A file anywhere else is invisible in the Codespaces creation UI. +DEVCONTAINER = REPO_ROOT / ".devcontainer" / "sre-agent-event-lab" / "devcontainer.json" + +SCENARIO_GUIDES = { + "02-scenario-s1.md": "s1", + "03-scenario-s2.md": "s2", + "04-scenario-s3.md": "s3", +} + +# Every value the manual walkthrough consumes, and the azd output each one +# is resolved from. +EXPORTED_VALUES = { + "RESOURCE_GROUP": "AZURE_RESOURCE_GROUP", + "SUBSCRIPTION_ID": "AZURE_SUBSCRIPTION_ID", + "APP_NAME": "AZURE_CONTAINER_APP_NAME", + "APP_FQDN": "AZURE_CONTAINER_APP_FQDN", + "WORKLOAD_PRINCIPAL_ID": "AZURE_CONTAINER_APP_PRINCIPAL_ID", + "STORAGE_CONTAINER_SCOPE": "AZURE_STORAGE_CONTAINER_SCOPE", + "BLOB_ROLE_ASSIGNMENT_NAME": "AZURE_BLOB_ROLE_ASSIGNMENT_NAME", +} + + +def manual_section(name): + text = (GUIDES / name).read_text() + return text[text.index("## 수동 실행"):text.index("## 지름길")] + + +# --- Codespaces entry point ------------------------------------------------ + + +def test_codespaces_offers_a_configuration_for_this_lab(): + assert DEVCONTAINER.is_file(), ( + "Codespaces only lists configurations under the repository's own " + f".devcontainer directory; expected {DEVCONTAINER}" + ) + config = json.loads(DEVCONTAINER.read_text()) + assert config["name"] + features = " ".join(config.get("features", {})) + for required in ("azure-cli", "azure-dev/azd", "github-cli", "python"): + assert required in features, required + + +def test_devcontainer_records_no_secret_and_no_subscription(): + raw = DEVCONTAINER.read_text() + assert not re.search( + r"\b[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}\b", raw + ), "a GUID in the devcontainer would pin every user to one subscription" + for forbidden in ("PASSWORD", "SECRET", "TOKEN", "_KEY", "CONNECTION_STRING"): + assert forbidden not in raw.upper(), forbidden + + +def test_devcontainer_prepares_the_lab_without_logging_in_for_the_user(): + """`az login` is interactive and account-specific: the container may + prepare the workspace, but it must never attempt a login or bake in + credentials of whoever authored the file.""" + config = json.loads(DEVCONTAINER.read_text()) + lifecycle = " ".join( + str(config.get(hook, "")) + for hook in ("onCreateCommand", "postCreateCommand", "postStartCommand") + ) + assert "setup-venv.sh" in lifecycle + assert "az login" not in lifecycle + + +# --- One-shot environment -------------------------------------------------- + + +def test_lab_env_is_sourced_not_executed(): + """Sourcing is the whole point: an executed copy would export into a + child shell that exits immediately.""" + assert LAB_ENV_SH.is_file() + text = LAB_ENV_SH.read_text() + code = "\n".join( + line for line in text.splitlines() if not line.lstrip().startswith("#") + ) + assert "set -e" not in code, "set -e would kill the operator's shell" + assert "BASH_SOURCE" in code, "the script must detect how it was invoked" + + +def test_lab_env_exports_every_value_the_manual_walkthrough_uses(): + text = LAB_ENV_SH.read_text() + for shell_name, azd_name in EXPORTED_VALUES.items(): + assert shell_name in text, shell_name + assert azd_name in text, azd_name + # Whatever mechanism binds them, the values have to leave the script as + # exported variables the next command can read. + assert "export" in text + + +def run_sourced(tmp_path, azd_mode="ok", az_mode="ok", extra=""): + """Source `lab-env.sh` against stub `azd`/`az` and report what it set.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + + azd_stub = { + "ok": textwrap.dedent( + """\ + #!/bin/bash + case "$3" in + AZURE_RESOURCE_GROUP) echo rg-sre-lab ;; + AZURE_SUBSCRIPTION_ID) echo 11111111-2222-3333-4444-555555555555 ;; + AZURE_CONTAINER_APP_NAME) echo ca-sre-lab ;; + AZURE_CONTAINER_APP_FQDN) echo ca-sre-lab.example.io ;; + AZURE_CONTAINER_APP_PRINCIPAL_ID) echo 8c8a4f0e-0000-4000-8000-2b1f9a0c1234 ;; + AZURE_STORAGE_CONTAINER_SCOPE) echo /subscriptions/s/rg/containers/documents ;; + AZURE_BLOB_ROLE_ASSIGNMENT_NAME) echo 3f2504e0-4f89-11d3-9a0c-0305e82c3301 ;; + *) echo "" ;; + esac + """ + ), + # azd prints its failure on stdout, after a leading newline, and + # exits 1 -- adopting that text as a value is the bug this guards. + "fail": '#!/bin/bash\nprintf "\\nERROR: no environment\\n"\nexit 1\n', + }[azd_mode] + (bin_dir / "azd").write_text(azd_stub) + (bin_dir / "azd").chmod(0o755) + + az_stub = { + "ok": "#!/bin/bash\necho 11111111-2222-3333-4444-555555555555\n", + "fail": "#!/bin/bash\nexit 1\n", + }[az_mode] + (bin_dir / "az").write_text(az_stub) + (bin_dir / "az").chmod(0o755) + + script = textwrap.dedent( + """\ + source "{path}" + echo "READY=${{LAB_READY}}" + for name in {names}; do + eval "printf '%s=%s\\n' \\"${{name}}\\" \\"\\${{${{name}}}}\\"" + done + {extra} + """ + ).format( + path=LAB_ENV_SH, + names=" ".join(EXPORTED_VALUES), + extra=extra, + ) + return subprocess.run( + ["/bin/bash"], + input=script, + text=True, + capture_output=True, + cwd=str(LAB_ROOT), + env={**os.environ, "PATH": "{0}{1}{2}".format(bin_dir, os.pathsep, os.environ["PATH"])}, + ) + + +def parsed(result): + return dict( + line.split("=", 1) + for line in result.stdout.splitlines() + if "=" in line + ) + + +def test_sourcing_exports_the_resolved_values(tmp_path): + result = run_sourced(tmp_path) + + values = parsed(result) + assert values["READY"] == "1", result.stdout + result.stderr + assert values["RESOURCE_GROUP"] == "rg-sre-lab" + assert values["APP_FQDN"] == "ca-sre-lab.example.io" + assert values["BLOB_ROLE_ASSIGNMENT_NAME"] == "3f2504e0-4f89-11d3-9a0c-0305e82c3301" + + +def test_a_failed_lookup_reports_zero_and_exports_nothing(tmp_path): + """azd's error sentence must never become a resource name: a run built + on it would target `https://ERROR: .../api/orders`.""" + result = run_sourced(tmp_path, azd_mode="fail") + + values = parsed(result) + assert values["READY"] == "0" + for shell_name in EXPORTED_VALUES: + assert values[shell_name] == "", (shell_name, values[shell_name]) + assert "azd" in result.stderr.lower() + + +def test_sourcing_survives_a_missing_azure_login(tmp_path): + """A shell that dies on `source` strands the operator with no way to + read the very message telling them to log in.""" + result = run_sourced( + tmp_path, az_mode="fail", extra='echo "SHELL_ALIVE=yes"' + ) + + assert "SHELL_ALIVE=yes" in result.stdout + assert "az login" in result.stdout + result.stderr + + +def test_sourcing_never_prints_a_secret(tmp_path): + result = run_sourced(tmp_path) + + combined = (result.stdout + result.stderr).upper() + for forbidden in ("PASSWORD", "CLIENT_SECRET", "ACCESS_TOKEN", "BEARER "): + assert forbidden not in combined, forbidden + + +# --- Guides consume the exported values ------------------------------------ + + +def test_scenario_guides_source_the_environment_once(tmp_path): + for name in SCENARIO_GUIDES: + section = manual_section(name) + assert "source ./scripts/lab-env.sh" in section, name + + +def test_scenario_guides_stopped_rebinding_each_value(): + """The point of the shared environment is that the walkthrough reads + values instead of resolving them again in every guide.""" + for name in SCENARIO_GUIDES: + section = manual_section(name) + assert "azd env get-value" not in section, name + + +def test_scenario_guides_still_refuse_to_run_when_the_environment_is_missing(): + for name, scenario in SCENARIO_GUIDES.items(): + section = manual_section(name) + assert "LAB_READY" in section, name + assert "begin-run {0}".format(scenario) in section, name + + +# --- The container can actually run what it promises ------------------------ + + +def test_devcontainer_provides_uv_because_setup_venv_requires_it(): + """`setup-venv.sh` is uv-only by design and exits 1 without it, so a + container that runs it in postCreate must install uv or every Codespace + starts with a failed lifecycle hook and no `app/.venv`.""" + config = json.loads(DEVCONTAINER.read_text()) + lifecycle = " ".join( + str(config.get(hook, "")) + for hook in ("onCreateCommand", "postCreateCommand", "postStartCommand") + ) + if "setup-venv.sh" not in lifecycle: + return + raw = DEVCONTAINER.read_text() + assert "uv" in raw, ( + "setup-venv.sh refuses to run without uv; the container must supply it" + ) + + +# --- The repository URL never carries a credential -------------------------- + + +def normalized_repo_url(remote): + """Ask `lab-env.sh` itself what it would publish for a given remote.""" + script = ( + 'source "{path}" >/dev/null 2>&1\n' + 'lab_env_normalize_repo_url "{remote}"\n' + ).format(path=LAB_ENV_SH, remote=remote) + result = subprocess.run( + ["/bin/bash"], input=script, text=True, capture_output=True, + cwd=str(LAB_ROOT), + ) + return result.stdout.strip() + + +def test_a_password_containing_an_at_sign_leaves_nothing_behind(): + """A basic-auth password may itself contain `@`; git reads the *last* + one as the delimiter. Rebuilding a URL from such a remote would keep the + tail of the secret, so the remote is dropped instead.""" + published = normalized_repo_url("https://me:p@ssw0rd@github.com/me/repo.git") + + assert "ssw0rd" not in published, published + assert published == "" + + +def test_a_remote_with_an_embedded_token_is_refused(tmp_path): + """Cloning with `https://user:@github.com/...` is routine behind a + corporate proxy. Publishing that remote would put the token in the + terminal, in scrollback, and in every child process.""" + token = "ghp_000000000000000000000000000000000000" + + published = normalized_repo_url( + "https://x-access-token:{0}@github.com/acme/lab.git".format(token) + ) + + assert token not in published, "the credential survived normalization" + assert "x-access-token" not in published + assert published in ("", "https://github.com/acme/lab") + + +def test_an_ssh_remote_becomes_the_https_form_the_connector_expects(): + assert normalized_repo_url("git@github.com:acme/lab.git") == ( + "https://github.com/acme/lab" + ) + + +def test_a_plain_https_remote_is_published_without_its_git_suffix(): + assert normalized_repo_url("https://github.com/acme/lab.git") == ( + "https://github.com/acme/lab" + ) 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 e02006ba..4af44f6e 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 @@ -479,7 +479,8 @@ def test_s3_guide_documents_propagation_wait_and_manual_restore_command(): "--set-env-vars ORDER_DELAY_MS=0", ), "04-scenario-s3.md": ( - "az role assignment delete --ids", + "az role assignment delete", + "--ids", "az role assignment create", ), } @@ -1071,3 +1072,34 @@ def test_screenshot_alt_text_is_not_repeated_as_body_prose(): if sentence in body ] assert repeated == [], (path.name, repeated) + + +# --- The lab runs against the operator's own fork -------------------------- + +# The Agent's GitHub connector writes issues into the repository it is +# connected to. Naming this repository as that target would send every +# participant's incident into it, so the guides must point at a fork the +# operator owns. +UPSTREAM_SLUG = "hellices/devguidesample" + + +def test_agent_setup_tells_the_operator_to_connect_their_own_fork(): + text = (GUIDES / "01-agent-setup.md").read_text() + + assert "fork" in text.lower() or "포크" in text + assert "SRE_REPOSITORY_URL" in text + + +def test_no_operator_facing_document_names_the_upstream_as_the_connected_repo(): + """`validation-results.md` and the captured assets are a record of one + past run and may keep their URLs; the documents an operator follows must + not hand the upstream repository to the Agent as its issue target.""" + for path in [README] + [GUIDES / name for name in GUIDE_NAMES]: + assert UPSTREAM_SLUG not in path.read_text(), path.name + + +def test_agent_setup_warns_that_issues_land_in_the_connected_repository(): + text = (GUIDES / "01-agent-setup.md").read_text() + + section = text.split("## 연결할 원본", 1)[1].split("\n## ", 1)[0] + assert "이슈" in section