From a0adee400dca86e88edafa4fb723122feed0759a Mon Sep 17 00:00:00 2001 From: hellices Date: Sat, 15 Aug 2026 17:54:41 +0900 Subject: [PATCH 1/2] feat(sre-lab): start from a fork in Codespaces with one shared environment Add a Codespaces configuration and a sourced lab-env.sh so an operator logs in once and every later command reads exported values instead of rebinding each one. Point the Agent at the operator's own fork, since the connector files issues into whatever repository it is connected to. The environment refuses to publish a git remote that carries credentials, and stops the lab when the active CLI subscription is not the lab's. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sre-agent-event-lab/devcontainer.json | 35 ++ monitor/sre-agent-event-lab/README.md | 35 +- .../guides/01-agent-setup.md | 16 +- .../guides/02-scenario-s1.md | 23 +- .../guides/03-scenario-s2.md | 25 +- .../guides/04-scenario-s3.md | 32 +- .../sre-agent-event-lab/scripts/lab-env.sh | 217 ++++++++++++ .../scripts/tests/test_lab_env.py | 316 ++++++++++++++++++ .../scripts/tests/test_lab_guides.py | 34 +- 9 files changed, 673 insertions(+), 60 deletions(-) create mode 100644 .devcontainer/sre-agent-event-lab/devcontainer.json create mode 100755 monitor/sre-agent-event-lab/scripts/lab-env.sh create mode 100644 monitor/sre-agent-event-lab/scripts/tests/test_lab_env.py 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..a6e6e3f5 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,13 @@ 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_URL "${SRE_REPOSITORY_URL}" azd env set SRE_REPOSITORY_BRANCH "main" azd env set SRE_KNOWLEDGE_PATH "runbooks/incident-response.md" ``` +`SRE_REPOSITORY_URL`은 `source ./scripts/lab-env.sh`가 fork의 `origin`에서 감지한 값입니다. 다른 저장소를 쓰려면 `azd env set SRE_REPOSITORY_URL "https://github.com/<본인 계정>/<저장소>"`처럼 직접 지정하되, 본인이 이슈를 만들 수 있는 저장소여야 합니다. + 토큰, 연결 문자열, 클라이언트 비밀은 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 From 2eec7762138eab593770126014e8fc2f59e4f818 Mon Sep 17 00:00:00 2001 From: hellices Date: Sat, 15 Aug 2026 18:02:33 +0900 Subject: [PATCH 2/2] docs(sre-lab): only persist a repository URL that exists lab-env.sh leaves SRE_REPOSITORY_URL empty when the git remote carries credentials or git is unavailable. Gate the azd env set on a non-empty value so the environment is never configured with an empty repository. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- monitor/sre-agent-event-lab/guides/01-agent-setup.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 a6e6e3f5..4a7ab138 100644 --- a/monitor/sre-agent-event-lab/guides/01-agent-setup.md +++ b/monitor/sre-agent-event-lab/guides/01-agent-setup.md @@ -138,12 +138,17 @@ 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 "${SRE_REPOSITORY_URL}" 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`가 fork의 `origin`에서 감지한 값입니다. 다른 저장소를 쓰려면 `azd env set SRE_REPOSITORY_URL "https://github.com/<본인 계정>/<저장소>"`처럼 직접 지정하되, 본인이 이슈를 만들 수 있는 저장소여야 합니다. +`SRE_REPOSITORY_URL`은 `source ./scripts/lab-env.sh`가 `origin`에서 유추한 값입니다. 원격 URL에 자격 증명이 들어 있으면 `lab-env.sh`가 값을 비워 두므로, 위 분기가 직접 지정하라고 알려 줍니다. 어느 경우든 **본인이 이슈를 만들 수 있는 저장소**여야 합니다. 토큰, 연결 문자열, 클라이언트 비밀은 azd 환경에도 저장하지 않습니다. 인증은 Agent의 관리 ID와 포털 OAuth 흐름이 처리합니다.