From 48e8fc47ae190969e603fd701c36a8045e6f5578 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 28 May 2026 22:39:12 +0800 Subject: [PATCH 01/19] Add AWS S3 SigV4 region CI tests Co-Authored-By: An Qiuyu --- .github/scripts/generate_ci_config.sh | 54 ++++++++++++++++++++++++++- .github/workflows/e2e-test.yml | 23 ++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/.github/scripts/generate_ci_config.sh b/.github/scripts/generate_ci_config.sh index fa1fc7f..424caf0 100755 --- a/.github/scripts/generate_ci_config.sh +++ b/.github/scripts/generate_ci_config.sh @@ -3,7 +3,7 @@ set -xeuo pipefail if [[ $# -lt 2 ]]; then cat >&2 <<'USAGE' -Usage: generate_ci_config.sh [--minio] [--jupyterhub] [--weko] [--flowable] [--s3compatsigv4] [--s3compatsigv4-inst] +Usage: generate_ci_config.sh [--minio] [--aws-s3] [--jupyterhub] [--weko] [--flowable] [--s3compatsigv4] [--s3compatsigv4-inst] USAGE exit 1 fi @@ -12,6 +12,7 @@ OUTPUT=$1 BASE_CONFIG=$2 shift 2 MINIO=false +AWS_S3=false JUPYTERHUB=false WEKO=false FLOWABLE=false @@ -23,6 +24,9 @@ for arg in "$@"; do --minio) MINIO=true ;; + --aws-s3) + AWS_S3=true + ;; --jupyterhub) JUPYTERHUB=true ;; @@ -46,6 +50,11 @@ for arg in "$@"; do done +if [[ "${MINIO}" == "true" && "${AWS_S3}" == "true" ]]; then + echo "--minio and --aws-s3 both write storages_s3 and cannot be used together" >&2 + exit 1 +fi + cp "${BASE_CONFIG}" "${OUTPUT}" if [[ "${MINIO}" == "true" ]]; then @@ -74,13 +83,54 @@ s3compat_test_bucket_name_2: '${S3COMPAT_BUCKET_NAME_2}' s3compat_type_name_1: '${S3COMPAT_SERVICE_NAME}' s3compat_type_name_2: '${S3COMPAT_SERVICE_NAME}' EOF -else +elif [[ "${AWS_S3}" != "true" ]]; then cat >> "${OUTPUT}" <<'EOF' storages_s3: [] EOF fi +if [[ "${AWS_S3}" == "true" ]]; then + required_aws_s3_vars=( + AWS_S3_ACCESS_KEY + AWS_S3_SECRET_KEY + AWS_S3_LEGACY_REGION + AWS_S3_LEGACY_BUCKET_NAME + AWS_S3_V4_REGION + AWS_S3_V4_BUCKET_NAME + ) + + missing_aws_s3_vars=() + for var_name in "${required_aws_s3_vars[@]}"; do + if [[ -z "${!var_name:-}" ]]; then + missing_aws_s3_vars+=("${var_name}") + fi + done + + if [[ ${#missing_aws_s3_vars[@]} -gt 0 ]]; then + echo "AWS S3 test credentials are not set: ${missing_aws_s3_vars[*]}" >&2 + exit 1 + fi + + cat >> "${OUTPUT}" <&2 diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 5dfa497..c34d693 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -162,6 +162,19 @@ jobs: weko_enabled: false jupyterhub_enabled: false flowable_enabled: false + - name: user-aws-s3 + display_name: "User Tests (AWS S3 SigV4 Regions)" + include_admin: false + skip_admin: true + skip_metadata: true + skip_default_storage: true + skip_login: true + skip_130mb_upload: true + minio_enabled: false + aws_s3_enabled: true + weko_enabled: false + jupyterhub_enabled: false + flowable_enabled: false - name: admin-minio display_name: "Admin Tests (MinIO)" include_admin: true @@ -215,6 +228,7 @@ jobs: name: E2E ${{ matrix.test-group.display_name }} env: MINIO_ENABLED: ${{ matrix.test-group.minio_enabled == true && 'true' || 'false' }} + AWS_S3_ENABLED: ${{ matrix.test-group.aws_s3_enabled == true && 'true' || 'false' }} S3COMPATSIGV4_ENABLED: ${{ matrix.test-group.s3compatsigv4_enabled == true && 'true' || 'false' }} S3COMPATSIGV4_INST_ENABLED: ${{ matrix.test-group.s3compatsigv4_institutional_storage == true && 'true' || 'false' }} WEKO_ENABLED: ${{ matrix.test-group.weko_enabled == true && 'true' || 'false' }} @@ -226,6 +240,12 @@ jobs: MINIO_ENDPOINT: http://minio:9000 MINIO_REGION: us-east-1 MINIO_SERVICE_NAME: MinIO (CI) + AWS_S3_ACCESS_KEY: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_ACCESS_KEY || '' }} + AWS_S3_SECRET_KEY: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_SECRET_KEY || '' }} + AWS_S3_LEGACY_REGION: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_LEGACY_REGION || '' }} + AWS_S3_LEGACY_BUCKET_NAME: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_LEGACY_BUCKET_NAME || '' }} + AWS_S3_V4_REGION: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_V4_REGION || '' }} + AWS_S3_V4_BUCKET_NAME: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_V4_BUCKET_NAME || '' }} steps: - name: Checkout test repository @@ -1078,6 +1098,9 @@ jobs: if [ "${MINIO_ENABLED}" = "true" ]; then args+=(--minio) fi + if [ "${AWS_S3_ENABLED}" = "true" ]; then + args+=(--aws-s3) + fi if [ "${JUPYTERHUB_ENABLED}" = "true" ]; then args+=(--jupyterhub) fi From 1b8e51f32a29426eadb32bd5f25a65b0e18c7939 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Sat, 13 Jun 2026 11:44:31 +0800 Subject: [PATCH 02/19] fix(ci): separate S3 credentials and mask artifacts --- .github/scripts/generate_ci_config.sh | 24 ++++-- .github/scripts/mask_sensitive_artifacts.sh | 91 +++++++++++++++++++++ .github/workflows/e2e-test.yml | 34 +++++++- 3 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/mask_sensitive_artifacts.sh diff --git a/.github/scripts/generate_ci_config.sh b/.github/scripts/generate_ci_config.sh index 424caf0..d31a4dd 100755 --- a/.github/scripts/generate_ci_config.sh +++ b/.github/scripts/generate_ci_config.sh @@ -92,8 +92,10 @@ fi if [[ "${AWS_S3}" == "true" ]]; then required_aws_s3_vars=( - AWS_S3_ACCESS_KEY - AWS_S3_SECRET_KEY + AWS_S3_ACCESS_KEY_1 + AWS_S3_SECRET_KEY_1 + AWS_S3_ACCESS_KEY_2 + AWS_S3_SECRET_KEY_2 AWS_S3_LEGACY_REGION AWS_S3_LEGACY_BUCKET_NAME AWS_S3_V4_REGION @@ -112,6 +114,16 @@ if [[ "${AWS_S3}" == "true" ]]; then exit 1 fi + if [[ "${AWS_S3_ACCESS_KEY_1}" == "${AWS_S3_ACCESS_KEY_2}" ]]; then + echo "AWS_S3_ACCESS_KEY_1 and AWS_S3_ACCESS_KEY_2 must be different" >&2 + exit 1 + fi + + if [[ "${AWS_S3_SECRET_KEY_1}" == "${AWS_S3_SECRET_KEY_2}" ]]; then + echo "AWS_S3_SECRET_KEY_1 and AWS_S3_SECRET_KEY_2 must be different" >&2 + exit 1 + fi + cat >> "${OUTPUT}" < [ ...]" >&2 + exit 1 +fi + +base_dir=$(pwd) + +mask_value() { + local value="$1" + local length=${#value} + if (( length <= 8 )); then + printf '****' + return + fi + + local prefix="${value:0:4}" + local suffix="${value: -4}" + local mask_length=$((length - 8)) + local stars + stars=$(printf '%*s' "$mask_length" '' | tr ' ' '*') + printf '%s%s%s' "$prefix" "$stars" "$suffix" +} + +replace_in_file() { + local file="$1" + local value="$2" + local masked="$3" + + VALUE="$value" MASKED="$masked" perl -0pi -e 's/\Q$ENV{VALUE}\E/$ENV{MASKED}/g' "$file" +} + +replace_in_tree() { + local target_dir="$1" + local value="$2" + local masked="$3" + + find "$target_dir" -type f \ + \( -name '*.ipynb' -o -name '*.log' -o -name '*.json' -o -name '*.har' -o -name '*.html' -o -name '*.js' -o -name '*.txt' -o -name '*.yaml' -o -name '*.yml' \) \ + -print0 | + while IFS= read -r -d '' file; do + replace_in_file "$file" "$value" "$masked" + done + + find "$target_dir" -type f -name 'har.zip' -print0 | + while IFS= read -r -d '' zip_file; do + local tmp_dir + local output_zip + tmp_dir=$(mktemp -d) + case "$zip_file" in + /*) + output_zip="$zip_file" + ;; + *) + output_zip="$base_dir/$zip_file" + ;; + esac + unzip -q "$zip_file" -d "$tmp_dir" + find "$tmp_dir" -type f -print0 | + while IFS= read -r -d '' file; do + replace_in_file "$file" "$value" "$masked" + done + (cd "$tmp_dir" && zip -qr "$output_zip" .) + rm -rf "$tmp_dir" + done +} + +while IFS='=' read -r name value; do + case "$name" in + *ACCESS_KEY*|*SECRET_KEY*|*PASSWORD*|*TOKEN*) + ;; + *) + continue + ;; + esac + + if [[ -z "$value" || ${#value} -lt 8 ]]; then + continue + fi + + masked=$(mask_value "$value") + echo "::add-mask::$value" + + for target_dir in "$@"; do + if [[ -d "$target_dir" ]]; then + replace_in_tree "$target_dir" "$value" "$masked" + fi + done +done < <(env) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index c34d693..7f7031c 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -240,8 +240,10 @@ jobs: MINIO_ENDPOINT: http://minio:9000 MINIO_REGION: us-east-1 MINIO_SERVICE_NAME: MinIO (CI) - AWS_S3_ACCESS_KEY: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_ACCESS_KEY || '' }} - AWS_S3_SECRET_KEY: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_SECRET_KEY || '' }} + AWS_S3_ACCESS_KEY_1: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_ACCESS_KEY_1 || '' }} + AWS_S3_SECRET_KEY_1: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_SECRET_KEY_1 || '' }} + AWS_S3_ACCESS_KEY_2: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_ACCESS_KEY_2 || '' }} + AWS_S3_SECRET_KEY_2: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_SECRET_KEY_2 || '' }} AWS_S3_LEGACY_REGION: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_LEGACY_REGION || '' }} AWS_S3_LEGACY_BUCKET_NAME: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_LEGACY_BUCKET_NAME || '' }} AWS_S3_V4_REGION: ${{ matrix.test-group.aws_s3_enabled == true && secrets.AWS_S3_V4_REGION || '' }} @@ -1042,6 +1044,28 @@ jobs: docker-compose exec -T web python3 -m scripts.register_erad_metadata /tmp/erad_sample.csv echo "e-Rad data registered successfully" + - name: Validate and mask sensitive test credentials + working-directory: e2e-tests + run: | + set -euo pipefail + + bash .github/scripts/mask_sensitive_artifacts.sh /tmp/nonexistent-mask-target + + if [ "${AWS_S3_ENABLED}" = "true" ]; then + if [ -z "${AWS_S3_ACCESS_KEY_1}" ] || [ -z "${AWS_S3_SECRET_KEY_1}" ] || [ -z "${AWS_S3_ACCESS_KEY_2}" ] || [ -z "${AWS_S3_SECRET_KEY_2}" ]; then + echo "AWS S3 test credentials are not fully configured. Set AWS_S3_ACCESS_KEY_1, AWS_S3_SECRET_KEY_1, AWS_S3_ACCESS_KEY_2, and AWS_S3_SECRET_KEY_2 secrets." + exit 1 + fi + if [ "${AWS_S3_ACCESS_KEY_1}" = "${AWS_S3_ACCESS_KEY_2}" ]; then + echo "AWS_S3_ACCESS_KEY_1 and AWS_S3_ACCESS_KEY_2 must be different." + exit 1 + fi + if [ "${AWS_S3_SECRET_KEY_1}" = "${AWS_S3_SECRET_KEY_2}" ]; then + echo "AWS_S3_SECRET_KEY_1 and AWS_S3_SECRET_KEY_2 must be different." + exit 1 + fi + fi + - name: Prepare test configuration working-directory: e2e-tests run: | @@ -1175,6 +1199,12 @@ jobs: echo "ticket=$TICKET" >> $GITHUB_OUTPUT echo "Extracted ticket: $TICKET" + - name: Mask sensitive values in test artifacts + if: always() + working-directory: e2e-tests + run: | + bash .github/scripts/mask_sensitive_artifacts.sh result result-failed + - name: Generate Excel summary if: always() working-directory: e2e-tests From 0e83912dcba8b0eaa9e45c339085574e104e0417 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Mon, 20 Jul 2026 09:55:47 +0900 Subject: [PATCH 03/19] =?UTF-8?q?feat(s3):=20Amazon=20S3=E6=A9=9F=E9=96=A2?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=83=AC=E3=83=BC=E3=82=B8=E3=81=AEE2E?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=83=8E=E3=83=BC=E3=83=88=E3=83=96?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - テスト手順-管理者機能-S3-機関ストレージ.ipynb を新規作成 (S3CompatSigV4版をベースに、Amazon S3用にエンドポイントURL関連を除去) URL/entityIDは実環境の固有ホスト名を残さず、他ノートブックと同様に example.comプレースホルダ or None(.config.yaml/プロンプト入力で補完)とした - 取りまとめ-S3共通.ipynb: .config.yaml から未設定パラメータを補完する ローダーセルを追加(対話実行を容易にするため) - .gitignore: .claude/ (ローカルのAI開発ツール設定) を除外 --- .gitignore | 3 + ...343\203\254\343\203\274\343\202\270.ipynb" | 1171 +++++++++++++++++ ...\202\201-S3\345\205\261\351\200\232.ipynb" | 25 + 3 files changed, 1199 insertions(+) create mode 100644 "\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270.ipynb" diff --git a/.gitignore b/.gitignore index c524767..65b652f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ venv/ *~ .DS_Store +# Claude Code / Claude in Cowork local settings (machine-specific, not for repo) +.claude/ + # Test outputs and reports *.log *.har diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270.ipynb" new file mode 100644 index 0000000..682e946 --- /dev/null +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270.ipynb" @@ -0,0 +1,1171 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "890e324e-2d56-45e8-976e-677338943ad8", + "metadata": { + "tags": [ + "parameters" + ] + }, + "outputs": [], + "source": [ + "from datetime import datetime\n", + "from getpass import getpass\n", + "\n", + "admin_rdm_url = 'https://admin.test.rdm.example.com/'\n", + "rdm_url = 'https://test.rdm.example.com/'\n", + "idp_name_1 = 'GakuNin RDM IdP'\n", + "\n", + "\n", + "idp_username_1 = None\n", + "idp_password_1 = None\n", + "\n", + "# admin_idp_name を指定すると Embedded DS(IdP選択) 経由でログインする\n", + "admin_idp_name = None\n", + "admin_username = None\n", + "admin_password = None\n", + "# entityID 直接指定(Embedded DS バイパス用。管理者ログインがメール/パスワード形式でない環境でのみ必要)\n", + "admin_idp_entity_id = None\n", + "default_result_path = None\n", + "close_on_fail = False\n", + "transition_timeout = 60000\n", + "skip_failed_test = True\n", + "exclude_notebooks = []\n", + "\n", + "# 機関ストレージ設定用パラメータ\n", + "target_organization = None\n", + "\n", + "# Amazon S3 設定\n", + "s3_access_key = None\n", + "s3_secret_key = None\n", + "s3_bucket = None\n", + "\n", + "# Server Side Encryption: True = Yes\n", + "s3_server_side_encryption = False\n", + "\n", + "# 機関ストレージの表示名(ファイルツリーに表示される名前)\n", + "institutional_storage_name = 'NII Storage'\n", + "\n", + "# プロジェクト名プレフィックス\n", + "rdm_project_prefix = 'TEST-S3-INST-{}'.format(datetime.now().strftime('%Y%m%d-%H%M%S'))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2de45df6", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# .config.yaml があれば、未設定(None)のパラメータを同名キーで補完する\n", + "# (papermill実行時は先にパラメータが注入されるため、この処理は上書きしない)\n", + "import os as _os\n", + "import yaml as _yaml\n", + "\n", + "_cfg_path = '.config.yaml'\n", + "if _os.path.exists(_cfg_path):\n", + " with open(_cfg_path) as _f:\n", + " _cfg = _yaml.safe_load(_f) or {}\n", + " _loaded = []\n", + " for _k, _v in _cfg.items():\n", + " if _k in globals() and globals()[_k] is None and _v is not None:\n", + " globals()[_k] = _v\n", + " _loaded.append(_k)\n", + " print(f'.config.yaml から補完: {_loaded}')\n", + "else:\n", + " print('.config.yaml なし(プロンプト入力で続行)')\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "418932bd-fb02-42f3-a0e5-d7facb342edb", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "if idp_username_1 is None:\n", + " idp_username_1 = input(prompt=f'Username for {idp_name_1}')\n", + "if idp_password_1 is None:\n", + " idp_password_1 = getpass(prompt=f'Password for {idp_username_1}@{idp_name_1}')\n", + "if admin_username is None:\n", + " admin_username = input(prompt='Admin Email (管理者画面ログイン用)')\n", + "if admin_password is None:\n", + " admin_password = getpass(prompt=f'Password for {admin_username} (管理者画面)')\n", + "(len(idp_username_1), len(idp_password_1), len(admin_username), len(admin_password))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2b8c1c48-52db-4dc6-8eb9-e328c4cb10cd", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "if s3_access_key is None:\n", + " s3_access_key = input(prompt='S3 Access Key')\n", + "if s3_secret_key is None:\n", + " s3_secret_key = getpass(prompt='S3 Secret Key')\n", + "if s3_bucket is None:\n", + " s3_bucket = input(prompt='S3 Bucket Name')\n", + "(len(s3_access_key), len(s3_secret_key), len(s3_bucket))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3824eb8a-f08b-4213-8c94-ab3e4f6151c4", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import tempfile\n", + "\n", + "work_dir = tempfile.mkdtemp()\n", + "if default_result_path is None:\n", + " default_result_path = work_dir\n", + "work_dir" + ] + }, + { + "cell_type": "markdown", + "id": "bbaf0777-4328-4abc-bdfd-afeb2b513c31", + "metadata": {}, + "source": [ + "# GakuNinRDM 総合テスト [Amazon S3 - 機関ストレージ]\n", + "\n", + "- サブシステム名: 管理者 / ストレージ\n", + "- ページ/アドオン: 機関ストレージ (Amazon S3)\n", + "- 機能分類: 機関ストレージ設定・ファイル操作確認\n", + "- シナリオ名: 管理者で機関ストレージにAmazon S3を設定し、ユーザ画面でプロジェクト作成後ファイルアップロードを確認する\n", + "- 用意するテストデータ: URL一覧、アカウント(既存ユーザー1: GRDM)、Amazon S3のアクセス情報(アクセスキー・シークレットキー・バケット)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "53c8bd5f-c6d0-4114-8526-7fe7df529122", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import importlib\n", + "import pandas as pd\n", + "\n", + "import scripts.playwright\n", + "importlib.reload(scripts.playwright)\n", + "\n", + "from scripts.playwright import *\n", + "from scripts import grdm\n", + "\n", + "await init_pw_context(close_on_fail=close_on_fail, last_path=default_result_path)" + ] + }, + { + "cell_type": "markdown", + "id": "cc6be5eb-51bf-4dc9-8660-dcb6a2d1c543", + "metadata": {}, + "source": [ + "## Part 1: 管理者画面での機関ストレージ設定\n", + "\n", + "### GakuNin RDM管理者ページのURLを開く\n", + "\n", + "管理者ページが表示されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cec2187f-5e72-45a5-8ebd-9c86d249fd55", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import time\n", + "\n", + "async def _step(page):\n", + " await page.goto(admin_rdm_url)\n", + "\n", + " await expect(page.locator('.login-logo')).to_be_visible(timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "4f067da0-2e81-4e24-ae8a-8a17baef89da", + "metadata": {}, + "source": [ + "### ログイン情報を用いてGakuNin RDM管理者画面にログインする\n", + "\n", + "(IdPに関するログイン情報が与えられた場合、)\n", + "GakuNin Embeded DSのプルダウンを展開し、IdPリストから指定されたIdPを選択する。その後、アカウントのID/Passwordを入力して「Login」ボタンを押下する。\n", + "\n", + "(IdPが指定されていない場合、)\n", + "CASのログイン操作を実施する。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9db4190-6ab0-415a-bb79-7e29b0ebc060", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import asyncio\n", + "import urllib.parse\n", + "\n", + "async def _step(page):\n", + " email_field = page.locator('#id_email')\n", + " if await email_field.count() > 0 and await email_field.is_visible():\n", + " # Django メール/パスワードフォーム(enable_form=True の環境)\n", + " await email_field.fill(admin_username)\n", + " await page.locator('#id_password').fill(admin_password)\n", + " await page.locator('//form//button[@type=\"submit\"]').click()\n", + " else:\n", + " # entityID 直接指定で Shibboleth ログインを開始(Embedded DS バイパス)\n", + " if admin_idp_entity_id is None:\n", + " raise ValueError(\n", + " 'admin_idp_entity_id が未設定です。管理者ログインがメール/パスワード形式でない場合は '\n", + " '.config.yaml または本ノートブックのパラメータで対象環境のIdP entityIDを指定してください。'\n", + " )\n", + " base = admin_rdm_url.rstrip('/')\n", + " url = (base + '/Shibboleth.sso/Login'\n", + " '?entityID=' + urllib.parse.quote(admin_idp_entity_id, safe='')\n", + " + '&target=' + urllib.parse.quote(base + '/account/shib-login', safe=''))\n", + " await page.goto(url)\n", + " print('[1] after goto:', page.url)\n", + "\n", + " title = await page.title()\n", + " assert 'Unknown Identity Provider' not in title, (\n", + " f'SPが entityID を認識していません: {admin_idp_entity_id}')\n", + "\n", + " username = page.locator('#username')\n", + " j_username = page.locator('input[name=\"j_username\"]')\n", + " consent = page.locator('#_shib_idp_doNotRememberConsent')\n", + " logout_link = page.locator('//*[@href=\"/account/logout/\"]')\n", + "\n", + " # IdPログインフォーム / 同意画面 / 既ログイン のいずれかを待つ\n", + " await expect(username.or_(j_username).or_(consent).or_(logout_link).first\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + " if await username.count() > 0 and await username.is_visible():\n", + " await username.fill(admin_username)\n", + " await page.locator('#password').fill(admin_password)\n", + " await page.locator('//button[@type=\"submit\"] | //input[@type=\"submit\"]').first.click()\n", + " print('[2] after credential submit:', page.url)\n", + " elif await j_username.count() > 0 and await j_username.is_visible():\n", + " await j_username.fill(admin_username)\n", + " await page.locator('input[name=\"j_password\"]').fill(admin_password)\n", + " await page.locator('//button[@type=\"submit\"] | //input[@type=\"submit\"]').first.click()\n", + " print('[2] after credential submit (j_username):', page.url)\n", + " else:\n", + " print('[2] ログインフォームなし(既ログイン or 同意画面):', page.url)\n", + "\n", + " # ログイン失敗の検出(IdPのエラー表示)\n", + " await asyncio.sleep(2)\n", + " err = page.locator('.form-error, .output--error, p.form-element.form-error')\n", + " if await err.count() > 0 and await err.first.is_visible():\n", + " raise AssertionError('IdPログイン失敗: ' + (await err.first.text_content()).strip())\n", + "\n", + " # 同意画面(出ない場合はスキップ)\n", + " try:\n", + " await expect(consent).to_be_visible(timeout=15000)\n", + " await consent.click()\n", + " proceed = page.locator('//*[@name=\"_eventId_proceed\"]')\n", + " await expect(proceed).to_be_enabled()\n", + " await proceed.click()\n", + " print('[3] consent done:', page.url)\n", + " except AssertionError:\n", + " print('[3] 同意画面はスキップ:', page.url)\n", + "\n", + " # ログアウトボタンまたはlogoutリンクが表示されること(日本語/英語環境両対応)\n", + " logout_btn = page.locator('//*[contains(@class, \"btn-danger\") and contains(text(), \"ログアウト\")]')\n", + " logout_link2 = page.locator('//*[@href=\"/account/logout/\"]')\n", + " try:\n", + " await expect(logout_btn.or_(logout_link2)).to_be_visible(timeout=transition_timeout)\n", + " except AssertionError:\n", + " print('[4] 最終確認失敗。現在地:', page.url, '/', await page.title())\n", + " body = await page.evaluate('() => document.body.innerText.substring(0, 800)')\n", + " print(body)\n", + " raise\n", + " print('[4] 管理者画面ログイン成功:', page.url)\n", + "\n", + "await run_pw(_step)\n" + ] + }, + { + "cell_type": "markdown", + "id": "0e1679bc-a65f-4325-832c-3ed637e231a7", + "metadata": {}, + "source": [ + "### サイドメニューの「機関ストレージ」を選択する\n", + "\n", + "機関ストレージの設定画面が表示されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb73ed05-ef20-4ea0-92cc-6f1624330770", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "async def _goto_inst(page): await page.goto(admin_rdm_url.rstrip('/') + '/custom_storage_location/institutional_storage/')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76e0c4df-11fa-4419-9626-cc25b01994bd", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "await run_pw(_goto_inst)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9e34ee0d-ebe6-4f9c-a4e8-7ab2bbe5f2b9", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import traceback\n", + "\n", + "async def _step(page):\n", + " # サイドメニューの「機関ストレージ」をクリック(「機関ストレージのクォータ」と区別)\n", + " link = page.locator('//a[contains(@href, \"institutional_storage\") and not(contains(@href, \"quota\"))]').first\n", + " await expect(link).to_be_visible(timeout=transition_timeout)\n", + " await link.click()\n", + "\n", + " # 機関ストレージ設定画面が表示されること(ラジオボタンの存在で確認)\n", + " await expect(page.locator('//input[@type=\"radio\"]').first).to_be_visible(timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "bda312c1-8995-4633-af91-f6b2262a30f7", + "metadata": {}, + "source": [ + "### 機関のリストから対象機関を選択する\n", + "\n", + "対象機関のInstitutional Storage設定画面が表示されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac7bd974-3285-42ef-b9ff-397d367c9d91", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# async def _step(page):\n", + "# while True:\n", + "# link = page.locator(f'//a[text() = \"{target_organization}\"]')\n", + "# try:\n", + "# await expect(link).to_be_visible()\n", + "# except:\n", + "# traceback.print_exc()\n", + "# print('Search next page...')\n", + "# next_button = page.locator('//a[i[contains(@class, \"fa-angle-right\")]]')\n", + "# if await next_button.count() == 0 or await next_button.is_disabled():\n", + "# raise Exception(f'Organization \"{target_organization}\" not found in any page')\n", + "# await next_button.click()\n", + "# await expect(page.locator('//h2[contains(text(), \"List of Institutions\") or contains(text(), \"機関のリスト\")]')).to_be_visible(timeout=transition_timeout)\n", + "# continue\n", + "# await link.click()\n", + "# break\n", + "\n", + "# await expect(page.locator('//h2[contains(text(), \"Institutional Storage\")]')).to_be_visible(timeout=transition_timeout)\n", + "\n", + "# await run_pw(_step)\n" + ] + }, + { + "cell_type": "markdown", + "id": "5a2732ce-d7dd-425a-88f5-4e65424ffef8", + "metadata": {}, + "source": [ + "### Amazon S3 を選択する\n", + "\n", + "Amazon S3 のラジオボタンが選択されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ba1b98f0-106a-418f-bfb6-c6b96a696f4e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "async def _step(page):\n", + " # Amazon S3 のラジオボタンを選択\n", + " # (既定で別プロバイダ(例: s3compatsigv4)が選択済みの場合があるため、check + 選択状態を検証する)\n", + " radio = page.locator('//input[@type=\"radio\" and @value=\"s3\"]')\n", + " await expect(radio).to_be_visible(timeout=transition_timeout)\n", + " await radio.scroll_into_view_if_needed()\n", + " try:\n", + " await radio.check()\n", + " except Exception:\n", + " await radio.check(force=True)\n", + " await expect(radio).to_be_checked()\n", + " selected = await page.evaluate('() => { const r = document.querySelector(\"input[name=options]:checked\"); return r ? r.value : \"none\"; }')\n", + " print('selected provider:', selected)\n", + " assert selected == 's3', f'Amazon S3 を選択できていません: {selected}'\n", + "\n", + "await run_pw(_step)\n" + ] + }, + { + "cell_type": "markdown", + "id": "3be35474-bb07-45e8-8dba-299587c246e8", + "metadata": {}, + "source": [ + "### 「Save」ボタンをクリックし、確認ダイアログで確認文字列を入力して「Change」をクリックする\n", + "\n", + "機関ストレージ変更の確認ダイアログが表示される。表示された確認文字列を入力し、「Change」をクリックするとAmazon S3の設定モーダルが表示されること。\n", + "\n", + "※ 確認文字列はランダムに生成されるため、画面を確認して手入力する。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c9d41a17-c5f4-4d09-b692-0fa23405314e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import asyncio\n", + "\n", + "async def _step(page):\n", + " global s3_modal_opened\n", + " s3_modal_opened = False\n", + "\n", + " # storage_name フィールドの状態を確認し、必要なら入力する\n", + " storage_name = page.locator('#storage_name')\n", + " sn_visible = await storage_name.is_visible()\n", + " sn_enabled = await storage_name.is_enabled() if sn_visible else False\n", + " sn_value = await storage_name.input_value() if sn_visible else ''\n", + " print(f'storage_name: visible={sn_visible}, enabled={sn_enabled}, value=\"{sn_value}\"')\n", + " if sn_visible and sn_enabled and not sn_value.strip():\n", + " await storage_name.fill(institutional_storage_name)\n", + " print(f'Filled storage_name: {institutional_storage_name}')\n", + "\n", + " # Save(保存) ボタンをクリック\n", + " save_btn = page.locator('//button[@type=\"submit\" and contains(@class, \"btn-success\")]').first\n", + " await save_btn.scroll_into_view_if_needed()\n", + " await save_btn.click()\n", + " await asyncio.sleep(2)\n", + "\n", + " confirm_text = page.locator('#bbConfirmText')\n", + " s3_modal = page.locator('#s3_modal')\n", + "\n", + " # 確認ダイアログ(bootbox)が出た場合は確認文字列を入力して変更を確定する\n", + " try:\n", + " await expect(confirm_text).to_be_visible(timeout=10000)\n", + " confirm_strong = page.locator('//div[contains(@class, \"bootbox-body\")]//strong')\n", + " confirmation_string = await confirm_strong.text_content()\n", + " print(f'Confirmation string: {confirmation_string}')\n", + " await confirm_text.fill(confirmation_string)\n", + " await page.locator('//div[contains(@class, \"modal\") and contains(@class, \"bootbox\")]//button[contains(@class, \"btn-danger\")]').click()\n", + " print('確認ダイアログを処理しました')\n", + " except:\n", + " print('確認ダイアログは表示されませんでした(変更なしの可能性)')\n", + "\n", + " # Amazon S3 認証モーダルが開いたか判定する(開かなければ既設定とみなしスキップ)\n", + " try:\n", + " await expect(s3_modal).to_be_visible(timeout=20000)\n", + " s3_modal_opened = True\n", + " print('Amazon S3 認証モーダルが表示されました(認証情報を入力します)')\n", + " except:\n", + " s3_modal_opened = False\n", + " selected = await page.evaluate('() => { var r = document.querySelector(\"input[name=options]:checked\"); return r ? r.value : \"none\"; }')\n", + " print(f'Amazon S3 認証モーダルは表示されませんでした。既に機関ストレージ設定済み(selectedProvider={selected})とみなし、認証情報入力〜保存のセルをスキップします。')\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "2595e57f-3c1f-4ef7-857f-8056df221193", + "metadata": {}, + "source": [ + "### Access Key、Secret Key、Bucketを入力し、Server Side EncryptionをYesに設定する\n", + "\n", + "各項目が正しく入力されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97aca39c-8969-40f8-aa83-400909b5f299", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import asyncio\n", + "\n", + "async def _step(page):\n", + " if not s3_modal_opened:\n", + " print('S3モーダル未表示のためスキップ(既設定)')\n", + " return\n", + " # Access Key を入力\n", + " await page.locator('#s3_access_key').fill(s3_access_key)\n", + "\n", + " # Secret Key を入力\n", + " await page.locator('#s3_secret_key').fill(s3_secret_key)\n", + "\n", + " # Bucket を入力\n", + " await page.locator('#s3_bucket').fill(s3_bucket)\n", + "\n", + " # Server Side Encryption のチェックボックス\n", + " # sse_checkbox = page.locator('#s3_server_side_encryption')\n", + " # is_checked = await sse_checkbox.is_checked()\n", + " # if s3_server_side_encryption and not is_checked:\n", + " # await sse_checkbox.click()\n", + " # elif not s3_server_side_encryption and is_checked:\n", + " # await sse_checkbox.click()\n", + "\n", + " # keyupイベントを発火させてバリデーションをトリガー\n", + " await page.evaluate('() => { document.querySelectorAll(\"#s3_modal input\").forEach(el => el.dispatchEvent(new Event(\"keyup\", { bubbles: true }))); }')\n", + "\n", + " # 入力後少し待つ\n", + " await asyncio.sleep(1)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "c3dee5f8-1791-467a-bb98-b3849f65070c", + "metadata": {}, + "source": [ + "### 「Connect」ボタンをクリックして接続テストを行う\n", + "\n", + "接続テストが成功すること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "378f2883-4828-4a08-821f-5cc6aa7d61e6", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "async def _step(page):\n", + " if not s3_modal_opened:\n", + " print('S3モーダル未表示のためスキップ(既設定)')\n", + " return\n", + " # Connect ボタンをクリック\n", + " connect_btn = page.locator('#s3_connect')\n", + " await expect(connect_btn).to_be_enabled(timeout=transition_timeout)\n", + " await connect_btn.click()\n", + "\n", + " # Save ボタンが有効になるのを待つ(接続成功の証)\n", + " save_btn = page.locator('#s3_save')\n", + " await expect(save_btn).to_be_enabled(timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "8860c955-50c1-4c43-a3b6-d167cf4327db", + "metadata": {}, + "source": [ + "### 「Save」ボタンをクリックして設定を保存する\n", + "\n", + "機関ストレージの設定が保存されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7ad1d8bd-0296-4937-954a-dbcadd1096e2", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import scripts.playwright as _sp\n", + "if s3_modal_opened:\n", + " _msg = await _sp.current_contexts[-1][1][-1].evaluate('() => document.getElementById(\"s3_message\") ? document.getElementById(\"s3_message\").innerText : \"no message element\"'); print('server message:', _msg)\n", + "else:\n", + " print('S3モーダル未表示のため server message 確認をスキップ(既設定)')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b4561ee1-2f62-45ef-bd75-cdda843cb1d6", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import scripts.playwright as _sp\n", + "if s3_modal_opened:\n", + " _r = await _sp.current_contexts[-1][1][-1].evaluate('async () => { const inst = (window.contextVars && window.contextVars.institution_id) || \"\"; const params = {provider_short_name: \"s3\", s3_access_key: document.getElementById(\"s3_access_key\").value, s3_secret_key: document.getElementById(\"s3_secret_key\").value, s3_bucket: document.getElementById(\"s3_bucket\").value, s3_server_side_encryption: document.getElementById(\"s3_server_side_encryption\").value}; const r = await fetch(\"/custom_storage_location/test_connection/\" + inst, {method: \"POST\", headers: {\"Content-Type\": \"application/json; charset=utf-8\", \"X-CSRFToken\": document.querySelector(\"[name=csrfmiddlewaretoken]\").value}, body: JSON.stringify(params), credentials: \"same-origin\"}); const t = await r.text(); return \"status=\" + r.status + \" body=\" + t.substring(0, 600); }'); print(_r)\n", + "else:\n", + " print('S3モーダル未表示のため test_connection 検証をスキップ(既設定)')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2a93959e-4273-4183-bbce-28589b7d09bc", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "async def _step(page):\n", + " if not s3_modal_opened:\n", + " print('S3モーダル未表示のためスキップ(既設定)')\n", + " return\n", + " # Save ボタンをクリック\n", + " save_btn = page.locator('#s3_save')\n", + " await save_btn.click()\n", + "\n", + " # モーダルが閉じるのを待つ\n", + " await expect(page.locator('#s3_modal')).to_be_hidden(timeout=transition_timeout)\n", + "\n", + " # 少し待って設定が反映されるのを確認\n", + " await asyncio.sleep(2)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "6a954f1b-5931-4546-88a8-3823fe58c2eb", + "metadata": {}, + "source": [ + "### 管理者画面からログアウトする\n", + "\n", + "ログアウトが完了すること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c6ca069b-ad5f-421b-a292-1e0685c343ad", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "async def _step(page):\n", + " # ログアウト(日本語/英語環境両対応)\n", + " logout_btn = page.locator('//*[contains(@class, \"btn-danger\") and contains(text(), \"ログアウト\")]')\n", + " logout_link = page.locator('//*[@href=\"/account/logout/\"]')\n", + " if await logout_btn.count() > 0:\n", + " await logout_btn.click()\n", + " else:\n", + " await logout_link.click()\n", + "\n", + " await expect(page.locator('.login-logo')).to_be_visible(timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "abb6c1d2-3d72-4e04-8e35-ae9eb8de3def", + "metadata": {}, + "source": [ + "## Part 2: ユーザ画面でのプロジェクト作成とファイルアップロード\n", + "\n", + "### GRDMトップページを表示する\n", + "\n", + "GRDMトップページが表示されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5f598e7b-c149-4fdb-9ba6-6ecb20ef01f8", + "metadata": {}, + "outputs": [], + "source": [ + "import scripts.grdm\n", + "import importlib\n", + "importlib.reload(scripts.grdm)\n", + "\n", + "\n", + "async def _step(page):\n", + " await page.goto(rdm_url)\n", + "\n", + " # ページの読み込みを待つ\n", + " await page.wait_for_load_state('networkidle')\n", + "\n", + " # 同意するボタンがあれば先にクリック\n", + " consent_button = page.locator('//button[text() = \"同意する\"]')\n", + " if await consent_button.count() > 0 and await consent_button.is_visible():\n", + " await consent_button.click()\n", + " await page.wait_for_load_state('networkidle')\n", + "\n", + " # 未ログインのGRDMトップページ(WAYF/Embedded DS)が表示されていることを確認する\n", + " # 実際のログインは次セルの scripts.grdm.login で行う\n", + " await scripts.grdm.expect_anonymous_toppage(page, idp_name_1, transition_timeout=transition_timeout)\n", + "\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "b6e5deb9-57ff-4b39-889d-afc57504ed23", + "metadata": {}, + "source": [ + "### ログイン情報を用いてGakuNin RDMにログインする\n", + "\n", + "(IdPに関するログイン情報が与えられた場合、)\n", + "GakuNin Embeded DSのプルダウンを展開し、IdPリストから指定されたIdPを選択する。その後、アカウントのID/Passwordを入力して「Login」ボタンを押下する。\n", + "\n", + "(IdPが指定されていない場合、)\n", + "CASのログイン操作を実施する。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f591bebe-3a0f-4b63-adb4-741f15c8c82b", + "metadata": {}, + "outputs": [], + "source": [ + "import scripts.grdm\n", + "importlib.reload(scripts.grdm)\n", + "\n", + "async def _step(page):\n", + " await scripts.grdm.login(\n", + " page, idp_name_1, idp_username_1, idp_password_1, transition_timeout=transition_timeout\n", + " )\n", + "\n", + " # ダッシュボードが表示されること(プロジェクト作成ボタンで確認)\n", + " await expect(page.locator('//*[@data-test-create-project-modal-button]')).to_be_visible(timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "a8288a84-8fa6-4e1c-95ac-b59cca649f46", + "metadata": {}, + "source": [ + "### プロジェクトを作成する\n", + "\n", + "「{rdm_project_prefix}-institutional」プロジェクトが作成されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a2439f85-8a2b-4997-b514-307283ffe893", + "metadata": {}, + "outputs": [], + "source": [ + "import scripts.grdm\n", + "importlib.reload(scripts.grdm)\n", + "\n", + "rdm_project_name = f'{rdm_project_prefix}-institutional'\n", + "\n", + "async def _step(page):\n", + " await expect(page.locator('//*[@data-test-create-project-modal-button]')).to_have_count(1)\n", + "\n", + " await scripts.grdm.ensure_project_exists(page, rdm_project_name, transition_timeout)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "d0cd996a-f08a-476a-92f1-cde36abc27ae", + "metadata": {}, + "source": [ + "### ダッシュボードのプロジェクト一覧から作成したプロジェクトをクリックする\n", + "\n", + "プロジェクトダッシュボードが表示されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "18aacbc0-4187-4706-b0c5-8a00d9e35d35", + "metadata": {}, + "outputs": [], + "source": [ + "async def _step(page):\n", + " await page.locator(f'//*[@data-test-dashboard-item-title and text()=\"{rdm_project_name}\"]').click()\n", + "\n", + " # プロジェクトダッシュボードのナビタブが表示されること\n", + " await expect(page.locator('#projectNavFiles')).to_be_visible(timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "3e72dbec-6f1e-4d93-9bf3-bc7387375ccc", + "metadata": {}, + "source": [ + "### 「ファイル」タブをクリックする\n", + "\n", + "ファイルタブが表示されること。NII Storageの代わりにAmazon S3の機関ストレージが表示されること。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6198b46f-c596-4bab-a491-10fec7b6be15", + "metadata": {}, + "outputs": [], + "source": [ + "async def _step(page):\n", + " await page.locator('#projectNavFiles a').click()\n", + "\n", + " await expect(page.locator('//*[@id = \"treeGrid\"]')).to_be_visible(timeout=transition_timeout)\n", + "\n", + " # 少し待ってファイルツリーが読み込まれるのを確認\n", + " await asyncio.sleep(3)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "eebda761-0dba-4fa1-a58f-7301c01ec39e", + "metadata": {}, + "source": [ + "### テスト用ファイルを作成する\n", + "\n", + "テスト用のファイルが作成されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1e46b1c0-30b3-4f01-8131-f016b481afe8", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "\n", + "test_file_name = 'test_institutional_storage.txt'\n", + "test_file_path = os.path.join(work_dir, test_file_name)\n", + "with open(test_file_path, 'w') as f:\n", + " f.write(f'Institutional Storage Test - Amazon S3\\n')\n", + " f.write(f'Created at: {datetime.now().isoformat()}\\n')\n", + " f.write(f'Bucket: {s3_bucket}\\n')\n", + "\n", + "print(f'Test file created: {test_file_path}')" + ] + }, + { + "cell_type": "markdown", + "id": "8d30a467-dbfa-41ad-8ceb-139dbefff42f", + "metadata": {}, + "source": [ + "### ファイルをアップロードする\n", + "\n", + "ファイルがアップロードされ、ファイル一覧に表示されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aa4930e0-7afb-43ec-b92d-9e08423c5102", + "metadata": {}, + "outputs": [], + "source": [ + "async def _step(page):\n", + " # 機関ストレージプロバイダをクリックして開く\n", + " await page.locator(f'//*[contains(text(), \"{institutional_storage_name}\")]').click()\n", + " await asyncio.sleep(2)\n", + "\n", + " # アップロードボタンが表示されるのを待つ\n", + " upload_btn = page.locator('//i[contains(@class, \"fa-upload\")]/../*[text() = \"アップロード\"]')\n", + " await expect(upload_btn).to_be_visible(timeout=transition_timeout)\n", + "\n", + " await scripts.grdm.upload_file(page, test_file_path)\n", + "\n", + " # アップロードしたファイルが表示されるのを確認\n", + " await expect(page.locator(f'//*[contains(text(), \"{test_file_name}\")]')).to_be_visible(timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "9069b114-dec1-4366-8734-c1935205fd6b", + "metadata": {}, + "source": [ + "### アップロードしたファイルをクリックして内容を確認する\n", + "\n", + "ファイルの内容が表示されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "998fd6eb-f4cd-482a-9e39-9b834a91f988", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import os\n", + "\n", + "async def _step(page):\n", + " if 'files' in page.url and test_file_name in await page.title():\n", + " pass\n", + " else:\n", + " await grdm.get_select_file_title_locator(page, test_file_name).click(timeout=transition_timeout)\n", + " await asyncio.sleep(3)\n", + "\n", + " # ファイル詳細ページが表示されていることを確認(ファイル名ヘッダーの拡張子)\n", + " filename_body, filename_ext = os.path.splitext(test_file_name)\n", + " await expect(page.locator(f'//h2[contains(text(), \"{filename_body}\")]//*[@id = \"file-ext\"]')).to_have_text(filename_ext, timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "8e4617ad-eaf5-4c6b-8522-80b566de8a3c", + "metadata": {}, + "source": [ + "### プロジェクトを削除する\n", + "\n", + "プロジェクトが削除されること" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7cf5498e-f791-4906-9e55-f278ff9a6be0", + "metadata": {}, + "outputs": [], + "source": [ + "async def _step(page):\n", + " await scripts.grdm.delete_project(page)\n", + "\n", + " # ダッシュボードに戻ったことを確認\n", + " await expect(page.locator('//*[@data-test-create-project-modal-button]')).to_be_visible(timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ] + }, + { + "cell_type": "markdown", + "id": "b4cb3797-1a59-46f0-bdd1-b2e33b2fb54f", + "metadata": {}, + "source": [ + "## Part 3: 後処理\n", + "\n", + "### 「ファイル基本操作」テストの実施(機関ストレージ利用時)\n", + "\n", + "テスト「テスト手順-ストレージ共通-ファイル基本操作」を機関ストレージ設定下でプロジェクトダッシュボードにて実施する。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c70ad802-c472-40db-80e6-1482425f20a6", + "metadata": {}, + "outputs": [], + "source": [ + "from datetime import datetime\n", + "import os\n", + "import papermill as pm\n", + "import traceback\n", + "from scripts.papermillHelpers import gen_run_notebook\n", + "\n", + "def make_result_dir(base_path):\n", + " result_dir = os.path.join(base_path, 'notebooks')\n", + " os.makedirs(result_dir, exist_ok=True)\n", + " return result_dir\n", + "\n", + "result_dir = make_result_dir(default_result_path)\n", + "\n", + "run_notebook = gen_run_notebook(\n", + " result_dir,\n", + " transition_timeout,\n", + " dict(\n", + " rdm_url=rdm_url,\n", + " idp_name_1=idp_name_1,\n", + " idp_username_1=idp_username_1,\n", + " idp_password_1=idp_password_1,\n", + " ),\n", + " skip_failed_test,\n", + " exclude_notebooks,\n", + ")\n", + "\n", + "result_notebooks = []\n", + "result_dir" + ] + }, + { + "cell_type": "markdown", + "id": "a2e6f5f5-a47b-4c93-871c-9911f18c5fc8", + "metadata": {}, + "source": [ + "### プロジェクトダッシュボードでの「ファイル基本操作」テストの実施\n", + "\n", + "テスト「テスト手順-ストレージ共通-ファイル基本操作」をプロジェクトダッシュボードで実施する。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c147c50c-7dc4-4be7-8fbb-d98197761f23", + "metadata": {}, + "outputs": [], + "source": [ + "result_notebooks.append(run_notebook(\n", + " 'テスト手順-ストレージ共通-ファイル基本操作.ipynb',\n", + " dict(\n", + " enable_52gb_file_upload=False,\n", + " target_storage_name=institutional_storage_name,\n", + " target_storage_id='osfstorage',\n", + " target_file_view='project-dashboard',\n", + " rdm_project_name=f'{rdm_project_prefix}-dashboard',\n", + " ),\n", + "))\n", + "result_notebooks" + ] + }, + { + "cell_type": "markdown", + "id": "92c54c10-cfd8-4015-a137-ca7e16c7694f", + "metadata": {}, + "source": [ + "### ファイルタブでの「ファイル基本操作」テストの実施\n", + "\n", + "テスト「テスト手順-ストレージ共通-ファイル基本操作」をファイルタブで実施する。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ce8d4388-67ab-45de-9a56-fa875222b292", + "metadata": {}, + "outputs": [], + "source": [ + "result_notebooks.append(run_notebook(\n", + " 'テスト手順-ストレージ共通-ファイル基本操作.ipynb',\n", + " dict(\n", + " enable_52gb_file_upload=False,\n", + " target_storage_name=institutional_storage_name,\n", + " target_storage_id='osfstorage',\n", + " target_file_view='file-tab',\n", + " rdm_project_name=f'{rdm_project_prefix}-filetab',\n", + " ),\n", + " '-file-tab',\n", + "))\n", + "result_notebooks" + ] + }, + { + "cell_type": "markdown", + "id": "e66bd529-4264-4d41-ab6d-9c8e890f40a7", + "metadata": {}, + "source": [ + "終了処理を実施。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55a1972b-8445-40af-9080-f7e7b4ddb40b", + "metadata": {}, + "outputs": [], + "source": [ + "await finish_pw_context(timeout=300)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "45a23f07-21ea-4438-b045-a9df3a36de1b", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "!rm -fr {work_dir}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2896b057-e9f0-44cf-a494-ea6dac380477", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import scripts.playwright as _sp; print('contexts=', None if _sp.current_contexts is None else len(_sp.current_contexts)); await _sp.finish_pw_context(timeout=300)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "693d7734-6fe4-4428-875f-e568b2209027", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git "a/\345\217\226\343\202\212\343\201\276\343\201\250\343\202\201-S3\345\205\261\351\200\232.ipynb" "b/\345\217\226\343\202\212\343\201\276\343\201\250\343\202\201-S3\345\205\261\351\200\232.ipynb" index 19ae26e..14e1325 100644 --- "a/\345\217\226\343\202\212\343\201\276\343\201\250\343\202\201-S3\345\205\261\351\200\232.ipynb" +++ "b/\345\217\226\343\202\212\343\201\276\343\201\250\343\202\201-S3\345\205\261\351\200\232.ipynb" @@ -71,6 +71,31 @@ "rdm_project_prefix = 'TEST-{}-{}'.format(target_storage_id.upper(), datetime.now().strftime('%Y%m%d-%H%M%S'))" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# .config.yaml があれば、未設定(None)のパラメータを同名キーで補完する\n", + "# (papermill実行時は先にパラメータが注入されるため、この処理は上書きしない)\n", + "import os as _os\n", + "import yaml as _yaml\n", + "\n", + "_cfg_path = '.config.yaml'\n", + "if _os.path.exists(_cfg_path):\n", + " with open(_cfg_path) as _f:\n", + " _cfg = _yaml.safe_load(_f) or {}\n", + " _loaded = []\n", + " for _k, _v in _cfg.items():\n", + " if _k in globals() and globals()[_k] is None and _v is not None:\n", + " globals()[_k] = _v\n", + " _loaded.append(_k)\n", + " print(f'.config.yaml から補完: {_loaded}')\n", + "else:\n", + " print('.config.yaml なし(プロンプト入力で続行)')\n" + ] + }, { "cell_type": "code", "execution_count": null, From 25c5fa7df0136b69fc4a7ba388b647d266d40f93 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Tue, 21 Jul 2026 10:01:46 +0900 Subject: [PATCH 04/19] =?UTF-8?q?ci(s3):=20AWS=20S3=E3=83=AA=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=83=A7=E3=83=B3=E3=83=86=E3=82=B9=E3=83=88=E3=82=92?= =?UTF-8?q?=E6=97=A2=E5=AE=9A=E3=81=A7=E7=84=A1=E5=8A=B9=E5=8C=96=EF=BC=88?= =?UTF-8?q?=E3=83=91=E3=83=96=E3=83=AA=E3=83=83=E3=82=AF=E3=83=AA=E3=83=9D?= =?UTF-8?q?=E3=82=B8=E3=83=88=E3=83=AA=E5=AF=BE=E5=BF=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit user-aws-s3 マトリクス項目は実AWS認証情報のGitHub Secretsを必要とするが、 パブリックリポジトリではSecretを登録できず、有効なままだと認証情報未設定で CIが失敗する。そのため既定でコメントアウトし、ローカル/Secret設定済みforkで テストする場合のみ有効化する旨をコメントで明記した。 generate_ci_config.sh 側の --aws-s3 処理は温存(有効化時にそのまま使える)。 --- .github/workflows/e2e-test.yml | 36 ++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 7f7031c..3b09801 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -162,19 +162,29 @@ jobs: weko_enabled: false jupyterhub_enabled: false flowable_enabled: false - - name: user-aws-s3 - display_name: "User Tests (AWS S3 SigV4 Regions)" - include_admin: false - skip_admin: true - skip_metadata: true - skip_default_storage: true - skip_login: true - skip_130mb_upload: true - minio_enabled: false - aws_s3_enabled: true - weko_enabled: false - jupyterhub_enabled: false - flowable_enabled: false + # AWS S3 (SigV4リージョン) の実接続テスト。 + # このジョブは実AWSのアクセスキー等を GitHub Secrets + # (AWS_S3_ACCESS_KEY_1/2, AWS_S3_SECRET_KEY_1/2, + # AWS_S3_LEGACY_REGION, AWS_S3_LEGACY_BUCKET_NAME, + # AWS_S3_V4_REGION, AWS_S3_V4_BUCKET_NAME) + # から取得する。パブリックリポジトリでは Secret を登録できないため + # 既定では無効化している(有効なままだと認証情報未設定でCIが失敗する)。 + # + # ローカル / Secret を設定済みのプライベートfork でテストする場合のみ、 + # 以下のブロックのコメントを解除して有効化すること。 + # - name: user-aws-s3 + # display_name: "User Tests (AWS S3 SigV4 Regions)" + # include_admin: false + # skip_admin: true + # skip_metadata: true + # skip_default_storage: true + # skip_login: true + # skip_130mb_upload: true + # minio_enabled: false + # aws_s3_enabled: true + # weko_enabled: false + # jupyterhub_enabled: false + # flowable_enabled: false - name: admin-minio display_name: "Admin Tests (MinIO)" include_admin: true From 064bf0808cf6d04df63a5b87d36ebeee5cb78f57 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Fri, 25 Sep 2026 20:57:28 +0900 Subject: [PATCH 05/19] =?UTF-8?q?test(s3):=20SigV4=E5=9B=9E=E5=B8=B0?= =?UTF-8?q?=E8=A9=A6=E9=A8=93=E3=83=8E=E3=83=BC=E3=83=88=E3=83=96=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=81=A8=E3=82=A8=E3=83=A9=E3=83=BC=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=89=E6=8E=A1=E5=8F=96=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WaterButlerのAmazon S3プロバイダSigV4化に伴う回帰試験(S-0〜S-7)を 実施するテスト手順Notebookと、CompleteMultipartUploadのエラーコードを 採取してWaterButlerの判定表と突き合わせるスクリプトを追加する。 機関ストレージはosfstorage経由で提供されるため、一覧ページング・削除・ サーバ側コピー/移動はs3プロバイダに到達しない。これらの試験項目は S3アドオンを用いて実施する。 --- README.md | 5 + scripts/s3_complete_error_codes.py | 541 ++++ ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 2513 +++++++++++++++++ 3 files changed, 3059 insertions(+) create mode 100644 scripts/s3_complete_error_codes.py create mode 100644 "\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" diff --git a/README.md b/README.md index 9749131..e7f8548 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,11 @@ GRDMには、一般的なGUI操作を行うためのユーティリティ関数 全てのテスト手順Jupyter Notebookの実行結果には、動画でのスクリーンキャプチャを添付することで、状況の確認の助けとします。動画のシーンがテストのどの手順に対応しているかの参考にできるよう、実行中のセルの見出し文字列が字幕として挿入されます。 +## Amazon S3 SigV4 回帰試験 + +`テスト手順-管理者機能-S3-機関ストレージ-SigV4回帰.ipynb` は、WaterButler の Amazon S3 プロバイダを SigV4 化した際の回帰試験 (S-0〜S-7) を実施するテスト手順 Notebook です。既存の `テスト手順-管理者機能-S3-機関ストレージ.ipynb` とは別ファイルとして管理し、機関ストレージ登録・大量ファイル一覧・バージョニング削除・サーバ側コピー/移動を確認します。 +`scripts/s3_complete_error_codes.py` は、CompleteMultipartUpload が AWS S3 から返すエラーコードを採取して WaterButler の判定表と突き合わせる単体スクリプトです (Notebook の S-6 に対応)。`--dry-run` で実行計画のみを表示できます。認証情報は環境変数から読み込み、出力には含めません。 + ## セキュリティと機密情報の管理 このリポジトリをGitで管理・公開する際は、機密情報の流出を防ぐためにpre-commit hookを設定してください。 diff --git a/scripts/s3_complete_error_codes.py b/scripts/s3_complete_error_codes.py new file mode 100644 index 0000000..6ce3601 --- /dev/null +++ b/scripts/s3_complete_error_codes.py @@ -0,0 +1,541 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""S-6: AWS S3 が CompleteMultipartUpload に返す Error Code を実機採取する。 + +RDM-waterbutler の ``waterbutler/providers/s3/provider.py`` には +``DEFINITIVE_REJECTION_CODES``(9 コード)という表がある。これは +「CompleteMultipartUpload がこのコードで落ちたなら、オブジェクトは確実に +できていない(= NOT_COMMITTED)」と判断するための表で、**MinIO の実装と S3 の +仕様書から起こしたもので AWS S3 の実機では確認されていない**(provider.py の +同箇所のコメント)。TEST_SPEC E-1 はこの突合を求めている。 + +本スクリプトは AWS に対して壊れた CompleteMultipartUpload を投げ、返ってきた +Error Code を採取して表と突き合わせる。**コード側(表)は変更しない。** +表に無いコードが出た場合は、WaterButler の既定どおり ``UNKNOWN`` +(= オブジェクトができたかどうか分からない)として記録するだけ。 +これが TEST_SPEC E-1 の合格基準である。 + +使い方:: + + export AWS_ACCESS_KEY_ID=... + export AWS_SECRET_ACCESS_KEY=... + export E2E_S3_BUCKET=... + export E2E_S3_REGION=us-east-1 # 省略時 us-east-1 + python3 scripts/s3_complete_error_codes.py --out <証跡ディレクトリ> + +``--dry-run`` を付けると AWS には一切アクセスせず、呼び出し計画だけを印字する。 + +資格情報はコマンドライン引数では受け取らない(ps に出るため)。出力の直前に +アクセスキーが混ざっていないことを検査する。 +""" +import argparse +import json +import os +import re +import sys +import textwrap +from datetime import datetime + +# --------------------------------------------------------------------------- +# RDM-waterbutler waterbutler/providers/s3/provider.py の DEFINITIVE_REJECTION_CODES +# の写し。**ここを書き換えて突合を通すのは禁止**(表の妥当性を測るのが目的)。 +DEFINITIVE_REJECTION_CODES = frozenset({ + 'AccessDenied', + 'InvalidPart', + 'InvalidPartOrder', + 'EntityTooSmall', + 'EntityTooLarge', + 'MalformedXML', + 'SignatureDoesNotMatch', + 'InvalidAccessKeyId', + 'NoSuchBucket', +}) + +# S3 の最小パートサイズ。非最終パートがこれ未満だと EntityTooSmall になる。 +MIN_PART_SIZE = 5 * 1024 * 1024 + +OUT_JSON = 'S-6-complete-error-codes.json' +OUT_MD = 'S-6-complete-error-codes.md' + + +def classify(code): + """WaterButler が返す分類。表にあれば NOT_COMMITTED、無ければ既定の UNKNOWN。""" + return 'NOT_COMMITTED' if code in DEFINITIVE_REJECTION_CODES else 'UNKNOWN' + + +# --------------------------------------------------------------------------- +# ケース定義。説明は --dry-run の呼び出し計画にもそのまま使う。 +CASES = [ + ('no-such-upload', '存在しない UploadId で complete する', [ + 'create_multipart_upload(Key=)', + 'abort_multipart_upload(<本物の UploadId>) # 先に捨てる', + 'complete_multipart_upload(UploadId=<捨てた UploadId>, Parts=[])', + ]), + ('invalid-part', '実在するパートに偽の ETag を付けて complete する', [ + 'create_multipart_upload(Key=)', + 'upload_part(PartNumber=1, Body=<5MiB>)', + 'complete_multipart_upload(Parts=[{PartNumber: 1, ETag: "<偽 ETag>"}])', + 'abort_multipart_upload() # 後始末', + ]), + ('entity-too-small', '1MiB のパートを 2 つ上げて complete する', [ + 'create_multipart_upload(Key=)', + 'upload_part(PartNumber=1, Body=<1MiB>) # 非最終パートが 5MiB 未満', + 'upload_part(PartNumber=2, Body=<1MiB>)', + 'complete_multipart_upload(Parts=[1, 2])', + 'abort_multipart_upload() # 後始末', + ]), + ('invalid-part-order', '2 パートを逆順で complete する', [ + 'create_multipart_upload(Key=)', + 'upload_part(PartNumber=1, Body=<5MiB>)', + 'upload_part(PartNumber=2, Body=<5MiB>) # 両方 5MiB(サイズで落とさない)', + 'complete_multipart_upload(Parts=[2, 1]) # 降順', + 'abort_multipart_upload() # 後始末', + ]), + ('malformed-xml', 'Parts=[] で complete する', [ + 'create_multipart_upload(Key=)', + 'upload_part(PartNumber=1, Body=<5MiB>)', + 'complete_multipart_upload(MultipartUpload={"Parts": []})', + 'abort_multipart_upload() # 後始末', + ]), + ('access-denied', '(--with-access-denied のとき) PutObject を拒否する' + 'ポリシーの付いたバケットに complete する', [ + 'create_multipart_upload(Bucket=, Key=)', + 'upload_part(PartNumber=1, Body=<5MiB>)', + 'complete_multipart_upload(Parts=[1])', + 'abort_multipart_upload() # 後始末(これも拒否される場合がある)', + ]), + ('completed-twice', '正常に complete したあと、同じ UploadId でもう一度 complete する', [ + 'create_multipart_upload(Key=)', + 'upload_part(PartNumber=1, Body=<5MiB>)', + 'complete_multipart_upload(Parts=[1]) # 1 回目は成功する', + 'complete_multipart_upload(Parts=[1]) # 2 回目 ← これを記録する', + 'delete_object(Key=) # 後始末(1 回目で実体ができている)', + ]), +] + +CASE_NOTES = { + 'completed-twice': ( + 'NoSuchUpload が返るなら、「NoSuchUpload = オブジェクトは無い」とは' + '言えないことの実機根拠になる(完了済みの UploadId でも同じ答えが返るため)。' + 'provider.py が NoSuchUpload を DEFINITIVE_REJECTION_CODES に入れず' + 'UNKNOWN 既定にしているのは、まさにこの理由による。'), + 'access-denied': ( + '事前にユーザーが「s3:PutObject を拒否するバケットポリシー」を付けた' + 'バケットを用意し、環境変数 E2E_S3_DENY_BUCKET で渡すこと。'), +} + + +def redact(text): + """アクセスキーらしき文字列を伏せる。""" + out = re.sub(r'AKIA[0-9A-Z]{12,}', 'AKIA***', text) + out = re.sub(r'ASIA[0-9A-Z]{12,}', 'ASIA***', out) + return out + + +def assert_no_credentials(text, access_key=None, secret_key=None): + """出力に資格情報が混ざっていないことを確かめる。""" + assert 'AKIA' not in text.replace('AKIA***', ''), '出力にアクセスキーが残っている' + assert 'ASIA' not in text.replace('ASIA***', ''), '出力にアクセスキーが残っている' + if access_key: + assert access_key not in text, '出力にアクセスキーが残っている' + if secret_key: + assert secret_key not in text, '出力にシークレットキーが残っている' + + +# --------------------------------------------------------------------------- +def print_plan(args, bucket, region, deny_bucket): + print('S-6 CompleteMultipartUpload Error Code 採取 — 呼び出し計画 (--dry-run)') + print() + print(f' bucket : {bucket or ""}') + print(f' region : {region}') + print(f' key prefix : {args.prefix}') + print(f' out : {args.out}') + print(f' deny bucket : {deny_bucket or "(未設定 → access-denied はスキップ)"}') + print() + for name, desc, calls in CASES: + if name == 'access-denied' and not args.with_access_denied: + print(f' [skip] {name}: --with-access-denied が無いためスキップ') + continue + if name == 'access-denied' and not deny_bucket: + print(f' [skip] {name}: E2E_S3_DENY_BUCKET 未設定のためスキップ') + continue + print(f' [{name}] {desc}') + for c in calls: + print(f' - {c}') + if name in CASE_NOTES: + for line in textwrap.wrap(CASE_NOTES[name], 76): + print(f' # {line}') + print() + print('出力:') + print(f' {os.path.join(args.out, OUT_JSON)}') + print(f' {os.path.join(args.out, OUT_MD)}') + print() + print('※ --dry-run では AWS に一切アクセスしていない。') + return 0 + + +# --------------------------------------------------------------------------- +def _client(region): + import boto3 + from botocore.config import Config + return boto3.client( + 's3', + region_name=region, + config=Config(signature_version='s3v4', retries={'max_attempts': 3}), + ) + + +def _observe(fn): + """呼び出して ClientError から Code / Message / status を取り出す。 + + 例外が出なかった場合は ``code: None``(= 成功)を返す。 + + ClientError 以外も拾うのは、moto のような代替実装が生の例外 + (``KeyError`` など)を投げることがあり、そこで落ちると残りのケースの + 採取まで失われるため。AWS 実機では botocore が必ず ClientError にする。 + """ + from botocore.exceptions import ClientError + try: + fn() + except ClientError as e: + err = e.response.get('Error', {}) + meta = e.response.get('ResponseMetadata', {}) + return { + 'raised': True, + 'http_status': meta.get('HTTPStatusCode'), + 'code': err.get('Code'), + 'message': err.get('Message'), + } + except Exception as e: + return { + 'raised': True, + 'http_status': None, + 'code': None, + 'message': f'{type(e).__name__}: {e}', + 'unexpected_exception': True, + } + return {'raised': False, 'http_status': 200, 'code': None, 'message': None} + + +def _abort(client, bucket, key, upload_id): + try: + client.abort_multipart_upload(Bucket=bucket, Key=key, UploadId=upload_id) + except Exception as e: # 後始末の失敗は記録するだけ + return f'{type(e).__name__}' + return 'ok' + + +def _cleanup(client, bucket, key, upload_id, obs): + """ケースの後始末。complete が通ってしまった場合は実体も消す。 + + 「落ちるはず」の complete が通ることもありうる(moto では + invalid-part-order が 200 で通った)。その場合 abort は効かないので、 + オブジェクトが残らないよう delete_object も試す。 + """ + out = {'abort': _abort(client, bucket, key, upload_id)} + if not obs.get('raised'): + try: + client.delete_object(Bucket=bucket, Key=key) + out['delete_object'] = 'deleted' + except Exception as e: + out['delete_object'] = type(e).__name__ + return out + + +def run_cases(client, bucket, deny_bucket, prefix, with_access_denied): + body_5m = b'0' * MIN_PART_SIZE + body_1m = b'0' * (1024 * 1024) + results = [] + + def key_for(name): + return f'{prefix}{name}' + + # ---------------- 1. no-such-upload + key = key_for('no-such-upload') + up = client.create_multipart_upload(Bucket=bucket, Key=key)['UploadId'] + cleanup = _abort(client, bucket, key, up) + obs = _observe(lambda: client.complete_multipart_upload( + Bucket=bucket, Key=key, UploadId=up, + MultipartUpload={'Parts': [ + {'PartNumber': 1, 'ETag': '"d41d8cd98f00b204e9800998ecf8427e"'}]})) + results.append(('no-such-upload', obs, {'pre_abort': cleanup})) + + # ---------------- 2. invalid-part + key = key_for('invalid-part') + up = client.create_multipart_upload(Bucket=bucket, Key=key)['UploadId'] + client.upload_part(Bucket=bucket, Key=key, UploadId=up, PartNumber=1, Body=body_5m) + obs = _observe(lambda: client.complete_multipart_upload( + Bucket=bucket, Key=key, UploadId=up, + MultipartUpload={'Parts': [ + {'PartNumber': 1, 'ETag': '"00000000000000000000000000000000"'}]})) + results.append(('invalid-part', obs, _cleanup(client, bucket, key, up, obs))) + + # ---------------- 3. entity-too-small + key = key_for('entity-too-small') + up = client.create_multipart_upload(Bucket=bucket, Key=key)['UploadId'] + e1 = client.upload_part(Bucket=bucket, Key=key, UploadId=up, + PartNumber=1, Body=body_1m)['ETag'] + e2 = client.upload_part(Bucket=bucket, Key=key, UploadId=up, + PartNumber=2, Body=body_1m)['ETag'] + obs = _observe(lambda: client.complete_multipart_upload( + Bucket=bucket, Key=key, UploadId=up, + MultipartUpload={'Parts': [{'PartNumber': 1, 'ETag': e1}, + {'PartNumber': 2, 'ETag': e2}]})) + results.append(('entity-too-small', obs, _cleanup(client, bucket, key, up, obs))) + + # ---------------- 4. invalid-part-order + key = key_for('invalid-part-order') + up = client.create_multipart_upload(Bucket=bucket, Key=key)['UploadId'] + e1 = client.upload_part(Bucket=bucket, Key=key, UploadId=up, + PartNumber=1, Body=body_5m)['ETag'] + # 両方 5MiB にしておく。片方を小さくすると「非最終パートが小さい」に + # 引っかかって EntityTooSmall が先に返り、順序の判定にならない + # (moto ではそうなった。AWS 実機の順序はこのスクリプトで確かめる)。 + e2 = client.upload_part(Bucket=bucket, Key=key, UploadId=up, + PartNumber=2, Body=body_5m)['ETag'] + obs = _observe(lambda: client.complete_multipart_upload( + Bucket=bucket, Key=key, UploadId=up, + MultipartUpload={'Parts': [{'PartNumber': 2, 'ETag': e2}, + {'PartNumber': 1, 'ETag': e1}]})) + results.append(('invalid-part-order', obs, _cleanup(client, bucket, key, up, obs))) + + # ---------------- 5. malformed-xml + key = key_for('malformed-xml') + up = client.create_multipart_upload(Bucket=bucket, Key=key)['UploadId'] + client.upload_part(Bucket=bucket, Key=key, UploadId=up, PartNumber=1, Body=body_5m) + obs = _observe(lambda: client.complete_multipart_upload( + Bucket=bucket, Key=key, UploadId=up, MultipartUpload={'Parts': []})) + results.append(('malformed-xml', obs, _cleanup(client, bucket, key, up, obs))) + + # ---------------- 6. access-denied (任意) + if with_access_denied and deny_bucket: + key = key_for('access-denied') + try: + up = client.create_multipart_upload(Bucket=deny_bucket, Key=key)['UploadId'] + except Exception as e: + results.append(('access-denied', + {'raised': True, 'http_status': None, + 'code': getattr(e, 'response', {}).get( + 'Error', {}).get('Code', type(e).__name__), + 'message': str(e)}, + {'note': 'create_multipart_upload の時点で拒否された'})) + else: + try: + etag = client.upload_part(Bucket=deny_bucket, Key=key, UploadId=up, + PartNumber=1, Body=body_5m)['ETag'] + except Exception as e: + results.append(('access-denied', + {'raised': True, 'http_status': None, + 'code': getattr(e, 'response', {}).get( + 'Error', {}).get('Code', type(e).__name__), + 'message': str(e)}, + {'note': 'upload_part の時点で拒否された', + 'abort': _abort(client, deny_bucket, key, up)})) + else: + obs = _observe(lambda: client.complete_multipart_upload( + Bucket=deny_bucket, Key=key, UploadId=up, + MultipartUpload={'Parts': [{'PartNumber': 1, 'ETag': etag}]})) + results.append(('access-denied', obs, + _cleanup(client, deny_bucket, key, up, obs))) + + # ---------------- 7. completed-twice + key = key_for('completed-twice') + up = client.create_multipart_upload(Bucket=bucket, Key=key)['UploadId'] + etag = client.upload_part(Bucket=bucket, Key=key, UploadId=up, + PartNumber=1, Body=body_5m)['ETag'] + parts = {'Parts': [{'PartNumber': 1, 'ETag': etag}]} + first = _observe(lambda: client.complete_multipart_upload( + Bucket=bucket, Key=key, UploadId=up, MultipartUpload=parts)) + second = _observe(lambda: client.complete_multipart_upload( + Bucket=bucket, Key=key, UploadId=up, MultipartUpload=parts)) + extra = {'first_complete': first} + try: + client.head_object(Bucket=bucket, Key=key) + extra['object_exists_after_second'] = True + except Exception: + extra['object_exists_after_second'] = False + try: + client.delete_object(Bucket=bucket, Key=key) + extra['cleanup'] = 'deleted' + except Exception as e: + extra['cleanup'] = type(e).__name__ + results.append(('completed-twice', second, extra)) + + return results + + +def build_report(results, bucket, region, started_at, skipped): + cases = [] + for name, obs, extra in results: + code = obs.get('code') + cases.append({ + 'case': name, + 'raised': obs.get('raised'), + 'http_status': obs.get('http_status'), + 'code': code, + 'message': obs.get('message'), + 'in_definitive_table': bool(code) and code in DEFINITIVE_REJECTION_CODES, + 'wb_classification': classify(code) if code else 'N/A (例外が出なかった)', + 'extra': extra, + }) + + observed = {c['code'] for c in cases if c['code']} + uncollected = sorted(DEFINITIVE_REJECTION_CODES - observed) + unlisted = sorted(observed - DEFINITIVE_REJECTION_CODES) + + report = { + 'generated_at': datetime.now().isoformat(), + 'started_at': started_at, + 'bucket': bucket, + 'region': region, + 'definitive_rejection_codes': sorted(DEFINITIVE_REJECTION_CODES), + 'cases': cases, + 'skipped_cases': skipped, + 'observed_codes': sorted(observed), + 'uncollected_table_codes': uncollected, + 'codes_not_in_table': unlisted, + 'note': ('コード側(DEFINITIVE_REJECTION_CODES)は変更していない。' + '表に無いコードは WaterButler の既定どおり UNKNOWN として扱う' + '(TEST_SPEC E-1 の合格基準)。'), + } + return report + + +def build_markdown(report): + lines = [ + '# S-6: CompleteMultipartUpload の Error Code 採取 (E-1)', + '', + f'- 採取日時: {report["generated_at"]}', + f'- bucket: `{report["bucket"]}` / region: `{report["region"]}`', + '- 突合先: RDM-waterbutler `waterbutler/providers/s3/provider.py` ' + 'の `DEFINITIVE_REJECTION_CODES`(9 コード)', + '- **コード側は変更していない。** 表に無いコードは既定の `UNKNOWN` として記録する。', + '', + '## 採取結果', + '', + '| ケース | HTTP | Code | 表にある | WB の分類 | Message |', + '|---|---|---|---|---|---|', + ] + for c in report['cases']: + msg = (c['message'] or '').replace('|', r'\|') + if len(msg) > 80: + msg = msg[:77] + '...' + lines.append( + '| `{case}` | {http} | `{code}` | {tbl} | `{cls}` | {msg} |'.format( + case=c['case'], + http=c['http_status'], + code=c['code'] or '(例外なし)', + tbl='✓' if c['in_definitive_table'] else '—', + cls=c['wb_classification'], + msg=msg)) + + lines += ['', '## 9 コード表との突合', '', + '| Code | 今回採取 | WB の扱い |', '|---|---|---|'] + observed = set(report['observed_codes']) + for code in report['definitive_rejection_codes']: + lines.append('| `{}` | {} | NOT_COMMITTED |'.format( + code, '採取' if code in observed else '**未採取**')) + for code in report['codes_not_in_table']: + lines.append('| `{}` | 採取(**表に無い**) | UNKNOWN(既定) |'.format(code)) + + if report['uncollected_table_codes']: + lines += ['', '未採取のコード(本スクリプトの 7 ケースでは出せないもの):', ''] + for code in report['uncollected_table_codes']: + lines.append(f'- `{code}`') + + if report['skipped_cases']: + lines += ['', '## スキップしたケース', ''] + for name, why in report['skipped_cases'].items(): + lines.append(f'- `{name}`: {why}') + + lines += ['', '## 注記', '', report['note'], ''] + + twice = [c for c in report['cases'] if c['case'] == 'completed-twice'] + if twice: + c = twice[0] + lines += [ + '`completed-twice` は「1 回成功したあと同じ UploadId で complete を' + 'やり直す」ケース。返ってきたのは ' + f'`{c["code"] or "(例外なし)"}`、' + 'このとき対象オブジェクトは ' + f'{"存在する" if c["extra"].get("object_exists_after_second") else "存在しない"}。' + 'これが `NoSuchUpload` なら、**そのコードだけでは「オブジェクトは' + 'できていない」と断定できない**ことの実機根拠になる' + '(provider.py が `NoSuchUpload` を表に入れず UNKNOWN 既定にしている理由)。', + '', + ] + return '\n'.join(lines) + + +# --------------------------------------------------------------------------- +def main(argv=None): + parser = argparse.ArgumentParser( + description='AWS S3 の CompleteMultipartUpload 失敗コードを採取する (S-6 / E-1)') + parser.add_argument('--out', default=os.environ.get('E2E_EVIDENCE_DIR', '.'), + help='証跡の出力先ディレクトリ (既定: $E2E_EVIDENCE_DIR か .)') + parser.add_argument('--prefix', default='s6-complete-error/', + help='テスト用オブジェクトのキーの接頭辞') + parser.add_argument('--dry-run', action='store_true', + help='AWS にアクセスせず、呼び出し計画だけを印字する') + parser.add_argument('--with-access-denied', action='store_true', + help='access-denied ケースを実施する' + '(E2E_S3_DENY_BUCKET が必要)') + args = parser.parse_args(argv) + + bucket = os.environ.get('E2E_S3_BUCKET') + region = os.environ.get('E2E_S3_REGION', 'us-east-1') + deny_bucket = os.environ.get('E2E_S3_DENY_BUCKET') + access_key = os.environ.get('AWS_ACCESS_KEY_ID') + secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY') + + if args.dry_run: + return print_plan(args, bucket, region, deny_bucket) + + missing = [n for n, v in (('AWS_ACCESS_KEY_ID', access_key), + ('AWS_SECRET_ACCESS_KEY', secret_key), + ('E2E_S3_BUCKET', bucket)) if not v] + if missing: + print('環境変数が足りない: ' + ', '.join(missing), file=sys.stderr) + print('(--dry-run なら資格情報なしで呼び出し計画だけ見られる)', file=sys.stderr) + return 2 + + skipped = {} + if not args.with_access_denied: + skipped['access-denied'] = '--with-access-denied が指定されていない' + elif not deny_bucket: + skipped['access-denied'] = 'E2E_S3_DENY_BUCKET が未設定' + + started_at = datetime.now().isoformat() + client = _client(region) + results = run_cases(client, bucket, deny_bucket, args.prefix, + args.with_access_denied) + + report = build_report(results, bucket, region, started_at, skipped) + text_json = redact(json.dumps(report, indent=2, ensure_ascii=False)) + text_md = redact(build_markdown(report)) + assert_no_credentials(text_json, access_key, secret_key) + assert_no_credentials(text_md, access_key, secret_key) + + os.makedirs(args.out, exist_ok=True) + path_json = os.path.join(args.out, OUT_JSON) + path_md = os.path.join(args.out, OUT_MD) + with open(path_json, 'w', encoding='utf-8') as f: + f.write(text_json + '\n') + with open(path_md, 'w', encoding='utf-8') as f: + f.write(text_md + '\n') + + print(text_md) + print() + print('wrote:', path_json) + print('wrote:', path_md) + if report['codes_not_in_table']: + print() + print('[!] 表に無いコードを採取した: ' + + ', '.join(report['codes_not_in_table'])) + print(' コードは変更せず、UNKNOWN 既定のまま記録した' + '(TEST_SPEC E-1 の合格基準)。') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" new file mode 100644 index 0000000..1d27244 --- /dev/null +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -0,0 +1,2513 @@ +{ + "cells": [ + { + "cell_type": "code", + "id": "afcd213f-de3e-420f-8373-44232cb2821d", + "metadata": { + "tags": [ + "parameters" + ] + }, + "source": [ + "from datetime import datetime\n", + "from getpass import getpass\n", + "\n", + "# 本ノートブックは テスト手順-管理者機能-S3-機関ストレージ.ipynb の複製に\n", + "# SigV4 回帰(S-0〜S-5)のセルを足したもの。元のノートブックは変更していない。\n", + "\n", + "admin_rdm_url = 'https://admin.test.rdm.example.com/'\n", + "rdm_url = 'https://test.rdm.example.com/'\n", + "idp_name_1 = 'GakuNin RDM IdP'\n", + "\n", + "\n", + "idp_username_1 = None\n", + "idp_password_1 = None\n", + "\n", + "# admin_idp_name を指定すると Embedded DS(IdP選択) 経由でログインする\n", + "admin_idp_name = None\n", + "admin_username = None\n", + "admin_password = None\n", + "# entityID 直接指定(Embedded DS バイパス用。管理者ログインがメール/パスワード形式でない環境でのみ必要)\n", + "admin_idp_entity_id = None\n", + "default_result_path = None\n", + "close_on_fail = False\n", + "transition_timeout = 60000\n", + "skip_failed_test = True\n", + "exclude_notebooks = []\n", + "\n", + "# 機関ストレージ設定用パラメータ\n", + "target_organization = None\n", + "\n", + "# Amazon S3 設定\n", + "# 資格情報は .config.yaml か実行時プロンプトで与える。**ここに値を書かない**。\n", + "s3_access_key = None\n", + "s3_secret_key = None\n", + "\n", + "# S-1 / S-3 / S-5 用。通常バケット。s3_region_default に作ったもの\n", + "s3_bucket = None\n", + "# S-4 用。バージョニングを有効にしたバケット。s3_region_default に作ったもの\n", + "s3_bucket_versioned = None\n", + "# S-2 用。s3_region_explicit に作ったもの\n", + "s3_bucket_apne1 = None\n", + "\n", + "# リージョンは管理画面でもアドオン画面でも入力しない(セル [5] の「リージョンの\n", + "# 決まり方」参照)。この 2 つは boto3 のクライアント生成と証跡の記録にだけ使う。\n", + "s3_region_default = 'us-east-1'\n", + "s3_region_explicit = 'ap-northeast-1'\n", + "\n", + "# Server Side Encryption: True = Yes\n", + "s3_server_side_encryption = False\n", + "\n", + "# 機関ストレージの表示名(ファイルツリーに表示される名前)\n", + "institutional_storage_name = 'NII Storage'\n", + "\n", + "# S3 アドオン(外部ストレージ)の表示名と ID。S-3 / S-4 / S-5 で使う\n", + "addon_storage_name = 'Amazon S3'\n", + "addon_storage_id = 's3'\n", + "\n", + "# シナリオの実行スイッチ\n", + "run_apne1 = True\n", + "run_toomany = True\n", + "run_versioned = True\n", + "run_intra = True\n", + "# S-4 の X9 実機版(同一 Key に 1001 版)。既定は無効\n", + "run_versioned_bulk = False\n", + "\n", + "toomany_count = 1500\n", + "toomany_prefix = 'toomany/'\n", + "intra_copy_dest = 'intra-copy-dest/'\n", + "intra_move_dest = 'intra-move-dest/'\n", + "intra_file_size = 6 * 1024 * 1024\n", + "\n", + "# 証跡の保存先。既定は ../aws-s3-sigv4-improvement/evidence/e2e/run-\n", + "evidence_dir = None\n", + "\n", + "# プロジェクト名プレフィックス\n", + "rdm_project_prefix = 'TEST-S3-SIGV4-{}'.format(datetime.now().strftime('%Y%m%d-%H%M%S'))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "47f59846-0e3b-464e-9228-ced0e67e4356", + "metadata": { + "tags": [] + }, + "source": [ + "# .config.yaml があれば、未設定(None)のパラメータを同名キーで補完する\n", + "# (papermill実行時は先にパラメータが注入されるため、この処理は上書きしない)\n", + "import os as _os\n", + "import yaml as _yaml\n", + "\n", + "_cfg_path = '.config.yaml'\n", + "if _os.path.exists(_cfg_path):\n", + " with open(_cfg_path) as _f:\n", + " _cfg = _yaml.safe_load(_f) or {}\n", + " _loaded = []\n", + " for _k, _v in _cfg.items():\n", + " if _k in globals() and globals()[_k] is None and _v is not None:\n", + " globals()[_k] = _v\n", + " _loaded.append(_k)\n", + " print(f'.config.yaml から補完: {_loaded}')\n", + "else:\n", + " print('.config.yaml なし(プロンプト入力で続行)')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "87c6aab3-5fa5-4ebf-bd09-5d2445147b75", + "metadata": { + "tags": [] + }, + "source": [ + "if idp_username_1 is None:\n", + " idp_username_1 = input(prompt=f'Username for {idp_name_1}')\n", + "if idp_password_1 is None:\n", + " idp_password_1 = getpass(prompt=f'Password for {idp_username_1}@{idp_name_1}')\n", + "if admin_username is None:\n", + " admin_username = input(prompt='Admin Email (管理者画面ログイン用)')\n", + "if admin_password is None:\n", + " admin_password = getpass(prompt=f'Password for {admin_username} (管理者画面)')\n", + "(len(idp_username_1), len(idp_password_1), len(admin_username), len(admin_password))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "3e4b3ae2-90d4-45aa-a1c4-8df0d90f3976", + "metadata": { + "tags": [] + }, + "source": [ + "if s3_access_key is None:\n", + " s3_access_key = input(prompt='S3 Access Key')\n", + "if s3_secret_key is None:\n", + " s3_secret_key = getpass(prompt='S3 Secret Key')\n", + "if s3_bucket is None:\n", + " s3_bucket = input(prompt=f'S3 Bucket Name (通常 / {s3_region_default})')\n", + "if run_apne1 and s3_bucket_apne1 is None:\n", + " s3_bucket_apne1 = input(prompt=f'S3 Bucket Name (明示リージョン / {s3_region_explicit})')\n", + "if run_versioned and s3_bucket_versioned is None:\n", + " s3_bucket_versioned = input(prompt=f'S3 Bucket Name (バージョニング有効 / {s3_region_default})')\n", + "\n", + "# バケット名は証跡には残すが、ここでは長さだけ確認する(取り違え防止)\n", + "assert len({b for b in [s3_bucket, s3_bucket_apne1, s3_bucket_versioned] if b}) == \\\n", + " len([b for b in [s3_bucket, s3_bucket_apne1, s3_bucket_versioned] if b]), \\\n", + " '3 つのバケットは別名でなければならない'\n", + "(len(s3_access_key), len(s3_secret_key), len(s3_bucket))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "3def6c3a-ba26-4d4f-a77d-e7aeaeec4388", + "metadata": { + "tags": [] + }, + "source": [ + "import json\n", + "import os\n", + "import tempfile\n", + "\n", + "work_dir = tempfile.mkdtemp()\n", + "\n", + "# 証跡の保存先。E2E_PLAN §0 の指定どおり aws-s3-sigv4-improvement/evidence/e2e/ 配下。\n", + "if evidence_dir is None:\n", + " evidence_dir = os.environ.get(\n", + " 'E2E_EVIDENCE_DIR',\n", + " os.path.join(os.pardir, 'aws-s3-sigv4-improvement', 'evidence', 'e2e'))\n", + "run_id = datetime.now().strftime('%Y%m%d-%H%M%S')\n", + "run_dir = os.path.abspath(os.path.join(evidence_dir, f'run-{run_id}'))\n", + "os.makedirs(run_dir, exist_ok=True)\n", + "\n", + "# Playwright の成果物(video-N.webm / har.zip / console.log / last-screenshot.png /\n", + "# last-dom.html)は scripts/playwright.py:217-259 が default_result_path 配下へ書く。\n", + "# 既定のままだと work_dir に落ち、最終セルの `!rm -fr {work_dir}` ごと消える。\n", + "# har.zip は S-1(G-10)の判定に使うので、既定を run_dir にする。\n", + "if default_result_path is None:\n", + " default_result_path = run_dir\n", + "\n", + "\n", + "def evidence(name):\n", + " \"\"\"証跡ファイルのパスを作る。名前には S-番号を含めること(E2E_PLAN §2)。\"\"\"\n", + " return os.path.join(run_dir, name)\n", + "\n", + "\n", + "def record(name, text, quiet=False):\n", + " \"\"\"テキスト証跡を保存しつつ、ノートブックの出力にも残す。\n", + "\n", + " quiet=True のときは中身を印字しない(1500 件の一覧など、出力に流すと\n", + " ノートブックが読めなくなるもの)。\n", + " \"\"\"\n", + " path = evidence(name)\n", + " with open(path, 'w') as f:\n", + " f.write(text)\n", + " print(f'--- {path} ({len(text)} bytes)')\n", + " if not quiet:\n", + " print(text)\n", + " return path\n", + "\n", + "\n", + "print(run_dir)\n", + "print(work_dir)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "5fcbb5ff-9e05-44d4-a913-973157aacae4", + "metadata": {}, + "source": [ + "# GakuNinRDM 総合テスト [Amazon S3 — SigV4 回帰]\n", + "\n", + "- サブシステム名: 管理者 / ストレージ / WaterButler\n", + "- ページ/アドオン: 機関ストレージ (Amazon S3) および S3 アドオン\n", + "- 機能分類: SigV4 移行の回帰確認(実機)\n", + "- シナリオ名: AWS S3 を機関ストレージとアドオンの両方で使い、SigV4 署名・\n", + " presigned URL リダイレクト・1000 件超の一覧・バージョン削除・intra copy/move\n", + " が壊れていないことを確かめる\n", + "- **対象 PR: RDM-waterbutler#93(`feature/s3-sigv4`, head `cf2f87ab`)/ RDM-osf.io#746**\n", + "- 取る証跡: TEST_SPEC v1.8 の **P-6 / V-1 / I-2 / IF-3 / G-10** の実機証跡\n", + "- 用意するテストデータ: URL 一覧、アカウント(既存ユーザー1: GRDM)、管理者アカウント、\n", + " AWS S3 のアクセスキー/シークレットキーと **3 つのバケット**\n", + " (通常 / バージョニング有効 / `ap-northeast-1`)\n", + "\n", + "## シナリオと受入項目の対応\n", + "\n", + "| シナリオ | 受入項目 | 使うモード | 証跡 |\n", + "|---|---|---|---|\n", + "| S-0 環境確認 | IF-5 / R-5 | — | `S-0-jenkins-build.txt`(手動) / `S-0-params.txt` |\n", + "| S-1 機関ストレージ登録・アップロード・ダウンロード | IF-3 既定 / **G-10** | 機関ストレージ | `S-1-wb-responses.json` / `S-1-download-redirect.json` |\n", + "| S-2 明示リージョン(`ap-northeast-1`) | **IF-3** 明示 | 機関ストレージ | `S-2-wb-responses.json` |\n", + "| S-3 1000 件超フォルダ | **P-6** / R-6 | **S3 アドオン** | `S-3-listing-pages.json` / `S-3-cleanup.txt` |\n", + "| S-4 バージョニング有効バケットでの削除 | **V-1**(任意で V-2) | **S3 アドオン** | `S-4-versions-before.json` / `S-4-versions-after.json` |\n", + "| S-5 intra copy / move | **I-2** | **S3 アドオン** | `S-5-wb-responses.json` |\n", + "| S-6 CompleteMultipartUpload の Error Code 採取 | E-1 | — | `scripts/s3_complete_error_codes.py`(本ノートブックの外) |\n", + "\n", + "## なぜ S-3 / S-4 / S-5 は機関ストレージではなく **アドオン**で行うのか\n", + "\n", + "**AWS S3 を機関ストレージに登録した場合、ファイル操作は `s3` プロバイダに\n", + "直接は届かない。** 登録は `Region` を作るだけで、プロジェクトのファイルツリーは\n", + "`osfstorage` のままであり、`osfstorage` が内側で `s3` を呼ぶ形になる。根拠:\n", + "\n", + "| 事実 | 位置 |\n", + "|---|---|\n", + "| 管理画面の保存は `Region` を作る(`provider: 's3'`, `type: Region.INSTITUTIONS`) | RDM-osf.io `admin/rdm_custom_storage_location/utils.py` `save_s3_credentials` |\n", + "| 機関の全 Node の **`osfstorage`** アドオンにその Region を割り当てる | 同 `update_nodes_storage` → `website/util/quota.py` `update_node_storage` |\n", + "| WB へは `osfstorage` の設定として Region の `waterbutler_settings` が渡る | `osf/models/files.py` `serialize_waterbutler_settings` |\n", + "\n", + "その結果、`osfstorage` プロバイダの実装(RDM-waterbutler\n", + "`waterbutler/providers/osfstorage/provider.py`)により次のようになる。\n", + "\n", + "| 操作 | 内側の `s3` に届くか | 位置 |\n", + "|---|---|---|\n", + "| アップロード | **届く**(本体バイト列は内側プロバイダに置かれる) | `upload` |\n", + "| ダウンロード | **届く**。`accept_url` がそのまま渡り、`s3` が presigned URL を返して WB が 302 する | `download` → `provider.download(**download_kwargs)` |\n", + "| 一覧(metadata) | **届かない**。OSF の DB を読むだけ | `metadata` → `make_signed_request` |\n", + "| 削除 | **届かない**。OSF に DELETE を投げるだけで `DeleteObject` は呼ばれない | `delete` |\n", + "| intra copy / move | **届かない**。OSF の `hooks/copy`・`hooks/move` を叩くだけ | `_do_intra_move_or_copy` |\n", + "\n", + "さらに、1000 件超の追加読み込み(`next_token`)は fangorn が\n", + "**`s3` / `s3compat` / `s3compatinstitutions` / `s3compatsigv4` のときだけ**\n", + "親フォルダに `next_token` を積む実装になっている(RDM-osf.io\n", + "`website/static/js/fangorn.js` `_lazyLoadPreprocess`)。`osfstorage` は対象外。\n", + "\n", + "したがって **P-6(一覧のページング)・V-1(バージョン削除)・I-2(intra copy/move)は\n", + "機関ストレージ経由では 1 行も実行されない**。これらを実機で踏むには\n", + "`s3` アドオンをプロジェクトに接続するしかない。E2E_PLAN v0.2 §1 は\n", + "「外部ストレージ(アドオン)モードでの網羅は別途」としているが、**網羅ではなく\n", + "当該 3 項目を実行できる唯一の経路**としてアドオンを使う。\n", + "(この判断は PHASE_E2E_PREP_REPORT.md にも記載した)\n", + "\n", + "## リージョンの決まり方(IF-3 の検証方法)\n", + "\n", + "**リージョンは UI のどこにも入力欄がない。** 管理画面の保存値は\n", + "`{folder, encrypt_uploads, bucket, provider, type}` だけで、リージョンを含まない\n", + "(`save_s3_credentials`)。WB 側は `S3Provider.__init__` で `self.region = None` と\n", + "し、最初のリクエストの前に `_check_region()` が `GetBucketLocation` を投げて\n", + "決める(`waterbutler/providers/s3/provider.py`)。`endpoint_url` は\n", + "`https://s3..amazonaws.com` に固定され、SigV4 はそのホストごと署名する。\n", + "\n", + "よって **「明示リージョン」の検証は「`ap-northeast-1` に作ったバケットを登録する」\n", + "こと**で行う(S-2)。UI でリージョンを選ぶ手順は存在しない。\n", + "\n", + "## 実行前に用意しておくこと\n", + "\n", + "1. staging2 の **RDM-waterbutler が `cf2f87ab`(PR #93 head)**、\n", + " **RDM-osf.io が PR #746 の head** に差し替わっていること。\n", + " Jenkins `r-deploy` のビルドログを `S-0-jenkins-build.txt` に手で保存する(S-0)。\n", + "2. `.config.yaml`(リポジトリ直下、.gitignore 済み)に資格情報を書いておくか、\n", + " 実行時のプロンプトに入力する。**ノートブックにキーを書かない**。\n", + "3. AWS のバケットを 3 つ用意する。いずれも空であること。\n", + " - 通常バケット(`s3_bucket`, `us-east-1`)\n", + " - バージョニング**有効**バケット(`s3_bucket_versioned`, `us-east-1`)\n", + " - `ap-northeast-1` のバケット(`s3_bucket_apne1`)\n", + "4. 機関ストレージの登録は**上書き**である(後述)。実施前に合意を取ること。\n", + "\n", + "### 機関ストレージの登録は「追加」ではなく「上書き」である\n", + "\n", + "機関ストレージは機関あたり 1 つしかなく、この操作は**現在の設定を破棄する**。\n", + "保存後に `update_nodes_storage` / `change_allowed_for_institutions` /\n", + "`add_node_settings_to_projects` が走り、影響はその機関の全 Node・全ユーザーに及ぶ\n", + "(RDM-osf.io `admin/rdm_custom_storage_location/views.py`)。\n", + "**「登録を外す」という操作は無い。** 別のプロバイダで上書きするのが唯一の戻し方。\n", + "後始末チェックリスト(末尾)を必ず最後まで消化すること。" + ] + }, + { + "cell_type": "code", + "id": "3f349198-2a56-4698-a2bb-d9ae334b1947", + "metadata": { + "tags": [] + }, + "source": [ + "import asyncio\n", + "import importlib\n", + "import time\n", + "\n", + "import pandas as pd\n", + "\n", + "import scripts.playwright\n", + "importlib.reload(scripts.playwright)\n", + "\n", + "from scripts.playwright import *\n", + "from scripts import grdm\n", + "\n", + "await init_pw_context(close_on_fail=close_on_fail, last_path=default_result_path)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "3f887e6e-918c-4698-871f-18093b0fd12f", + "metadata": {}, + "source": [ + "### WaterButler への応答を記録する仕掛けを入れる\n", + "\n", + "以降の操作で WaterButler が返した **HTTP ステータス**と**本文**を捕まえる。\n", + "S-2(署名エラーが無いこと)・S-3(`next_token` の連鎖)・S-5(copy/move の POST)の\n", + "判定根拠になる。" + ] + }, + { + "cell_type": "code", + "id": "a7e47570-ebec-4c38-9fe7-691e6422bc4c", + "metadata": { + "tags": [] + }, + "source": [ + "wb_responses = []\n", + "\n", + "# 一覧応答(?meta=)は本文を解析するので切らない。それ以外は証跡が膨らむので切る。\n", + "WB_BODY_LIMIT = 2000\n", + "WB_BODY_LIMIT_META = 4 * 1024 * 1024\n", + "\n", + "\n", + "async def _attach_recorder(page):\n", + " async def _on_response(response):\n", + " url = response.url\n", + " if '/v1/resources/' not in url and '/wb/' not in url:\n", + " return\n", + " entry = {\n", + " 'status': response.status,\n", + " 'method': response.request.method,\n", + " 'url': url,\n", + " 'at': datetime.now().isoformat(),\n", + " }\n", + " limit = WB_BODY_LIMIT_META if 'meta=' in url else WB_BODY_LIMIT\n", + " try:\n", + " entry['body'] = (await response.text())[:limit]\n", + " except Exception as e:\n", + " entry['body'] = f''\n", + " wb_responses.append(entry)\n", + "\n", + " page.on('response', lambda r: asyncio.ensure_future(_on_response(r)))\n", + "\n", + "\n", + "async def _step(page):\n", + " await _attach_recorder(page)\n", + "\n", + "await run_pw(_step)\n", + "\n", + "\n", + "def _redact(text):\n", + " \"\"\"署名と資格情報を伏せる。証跡に出す前に必ず通す。\"\"\"\n", + " import re\n", + " out = text\n", + " for secret in [s3_secret_key, s3_access_key]:\n", + " if secret:\n", + " out = out.replace(secret, '***')\n", + " out = re.sub(r'(X-Amz-(?:Signature|Credential|Security-Token)=)[^&\\s\"\\\\]+',\n", + " r'\\1***', out)\n", + " out = re.sub(r'AKIA[0-9A-Z]{12,}', 'AKIA***', out)\n", + " return out\n", + "\n", + "\n", + "def dump_wb_responses(name, keep=False, quiet=True):\n", + " \"\"\"直近の操作で観測した WB 応答を証跡に落とす。\"\"\"\n", + " text = _redact(json.dumps(wb_responses, indent=2, ensure_ascii=False))\n", + " assert 'AKIA' not in text.replace('AKIA***', ''), '証跡にアクセスキーが残っている'\n", + " path = record(name, text, quiet=quiet)\n", + " if not keep:\n", + " wb_responses.clear()\n", + " return path\n", + "\n", + "\n", + "def wb_failures(entries=None):\n", + " \"\"\"4xx / 5xx と、署名系の文言を含む応答を拾う。\"\"\"\n", + " entries = wb_responses if entries is None else entries\n", + " bad_words = ('SignatureDoesNotMatch', 'AuthorizationHeaderMalformed',\n", + " 'PermanentRedirect', 'InvalidAccessKeyId')\n", + " out = []\n", + " for e in entries:\n", + " body = e.get('body') or ''\n", + " if e['status'] >= 400 or any(w in body for w in bad_words):\n", + " out.append(e)\n", + " return out\n", + "\n", + "\n", + "async def open_storage(page, storage_name):\n", + " \"\"\"ファイルツリーのストレージノードを開き、アップロードボタンが出る状態にする。\"\"\"\n", + " await page.locator(f'//*[contains(text(), \"{storage_name}\")]').first.click()\n", + " await asyncio.sleep(2)\n", + " upload_btn = page.locator('//i[contains(@class, \"fa-upload\")]/../*[text() = \"アップロード\"]')\n", + " await expect(upload_btn).to_be_visible(timeout=transition_timeout)\n", + "\n", + "\n", + "async def clear_growls(page):\n", + " \"\"\"残っている growl を消す。\n", + "\n", + " growl(`[data-growl=\"container\"]`)は自動では消えないことがあり、前の手順の\n", + " growl が残っていると「今の操作の結果」と区別がつかない。判定の直前に消す。\n", + " \"\"\"\n", + " await page.evaluate(\n", + " '() => { document.querySelectorAll(\\'[data-growl=\"container\"]\\')'\n", + " '.forEach(function(e) { e.remove(); }); }')\n", + "\n", + "\n", + "async def growl_messages(page):\n", + " \"\"\"今表示されている growl の本文。判定の根拠として証跡に残す。\"\"\"\n", + " return await page.locator('//*[@data-growl = \"message\"]').all_inner_texts()\n", + "\n", + "\n", + "async def uploading_rows(page, filename=None):\n", + " \"\"\"アップロード中(progressbar 付きの仮行)の数。\"\"\"\n", + " if filename is None:\n", + " return await page.locator('//*[@role = \"progressbar\"]').count()\n", + " return await page.locator(\n", + " f'//*[text() = \"{filename}\"]/../following-sibling::*//*[@role = \"progressbar\"]'\n", + " ).count()\n", + "\n", + "\n", + "async def wait_for_upload_finished(page, filename, timeout=None):\n", + " \"\"\"成否を問わず「アップロードが終わった」ことを待ち、経過秒を返す。\n", + "\n", + " fangorn は成功時も失敗時も仮行を消す。**仮行が残っている = XHR がまだ\n", + " 返っていない。** Dropzone は parallelUploads: 1 なので、終わっていない行を\n", + " 残したまま次の手順に進むと次のアップロードはキューで止まる。\n", + " \"\"\"\n", + " timeout = transition_timeout * 10 if timeout is None else timeout\n", + " started = time.time()\n", + " deadline = started + timeout / 1000\n", + " while time.time() < deadline:\n", + " if await uploading_rows(page, filename) == 0:\n", + " return time.time() - started\n", + " await asyncio.sleep(2)\n", + " raise AssertionError(\n", + " f'{filename} のアップロードが {timeout / 1000:.0f} 秒で終わらない')\n", + "\n", + "\n", + "def s3_client(region):\n", + " \"\"\"boto3 の S3 クライアント。資格情報はノートブックのパラメータから取る。\"\"\"\n", + " import boto3\n", + " from botocore.config import Config\n", + " return boto3.client(\n", + " 's3',\n", + " region_name=region,\n", + " aws_access_key_id=s3_access_key,\n", + " aws_secret_access_key=s3_secret_key,\n", + " config=Config(signature_version='s3v4', retries={'max_attempts': 5}),\n", + " )" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "0c3c1d46-ace3-40d6-9ee8-08205fd8ba9d", + "metadata": {}, + "source": [ + "## S-0: 環境確認(IF-5 / R-5)\n", + "\n", + "### 手で保存するもの — `S-0-jenkins-build.txt`\n", + "\n", + "Jenkins の `r-deploy` ジョブのビルドログから、次の行をコピーして\n", + "証跡ディレクトリに `S-0-jenkins-build.txt` として保存する。\n", + "**ビルドログを取らずに「差し替えたはず」で進めないこと。**\n", + "このジョブは WB だけでなく osf.io / CAS / MFR など 8 リポジトリを同時に\n", + "再ビルドするため、直前のビルドが誰の何だったかは追跡が要る。\n", + "\n", + "1. stage `build RDM-waterbutler` の\n", + " `Checking out Revision cf2f87ab...`\n", + " — PR RCOSDP/RDM-waterbutler#93 の head をビルドしたことの証明\n", + "2. stage `build RDM-osf.io` の\n", + " `Checking out Revision <#746 の head SHA>`\n", + " — PR RCOSDP/RDM-osf.io#746 の head をビルドしたことの証明。\n", + " SHA は実施者が PR 画面で確認して記録する\n", + "3. デプロイ段の\n", + " `helm upgrade -i rdm-wb ... --set image.tag=` と\n", + " `helm upgrade -i rdm-osf ... --set image.tag=`\n", + " — そのビルドが実際に staging2 に載ったことの証明\n", + "\n", + "`wb_deployed_sha` / `osf_deployed_sha` に手で控えて、次のセルで証跡に残す。" + ] + }, + { + "cell_type": "code", + "id": "43e43af5-9177-41f8-839f-aa7f412fc4a7", + "metadata": { + "tags": [] + }, + "source": [ + "# S-0: 実行時パラメータの記録。**キーは書かない**。\n", + "wb_deployed_sha = 'cf2f87ab' # PR RCOSDP/RDM-waterbutler#93 head\n", + "osf_deployed_sha = None # PR RCOSDP/RDM-osf.io#746 head。実施者が記入する\n", + "\n", + "s0 = {\n", + " 'run_id': run_id,\n", + " 'started_at': datetime.now().isoformat(),\n", + " 'admin_rdm_url': admin_rdm_url,\n", + " 'rdm_url': rdm_url,\n", + " 'wb_pr': 'RCOSDP/RDM-waterbutler#93',\n", + " 'wb_deployed_sha': wb_deployed_sha,\n", + " 'osf_pr': 'RCOSDP/RDM-osf.io#746',\n", + " 'osf_deployed_sha': osf_deployed_sha,\n", + " 'target_organization': target_organization,\n", + " 'institutional_storage_name': institutional_storage_name,\n", + " 'addon_storage_id': addon_storage_id,\n", + " 'buckets': {\n", + " 'default': {'name': s3_bucket, 'region': s3_region_default,\n", + " 'used_by': ['S-1', 'S-3', 'S-5']},\n", + " 'apne1': {'name': s3_bucket_apne1, 'region': s3_region_explicit,\n", + " 'used_by': ['S-2']},\n", + " 'versioned': {'name': s3_bucket_versioned, 'region': s3_region_default,\n", + " 'used_by': ['S-4']},\n", + " },\n", + " 'switches': {\n", + " 'run_apne1': run_apne1,\n", + " 'run_toomany': run_toomany,\n", + " 'run_versioned': run_versioned,\n", + " 'run_versioned_bulk': run_versioned_bulk,\n", + " 'run_intra': run_intra,\n", + " 'toomany_count': toomany_count,\n", + " },\n", + " 'rdm_project_prefix': rdm_project_prefix,\n", + "}\n", + "_text = json.dumps(s0, indent=2, ensure_ascii=False)\n", + "assert s3_access_key not in _text and s3_secret_key not in _text, \\\n", + " 'S-0-params.txt に資格情報が混ざっている'\n", + "record('S-0-params.txt', _text)\n", + "if osf_deployed_sha is None:\n", + " print('\\n[!] osf_deployed_sha が未記入。PR #746 の head SHA を上のセルに記入して再実行すること')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "2a6d7892-8fee-4585-985f-d23887ea25bd", + "metadata": { + "tags": [] + }, + "source": [ + "# S-0: バケットの前提を boto3 で確認する(空であること・リージョン・バージョニング)\n", + "def bucket_region(bucket):\n", + " loc = s3_client(s3_region_default).get_bucket_location(Bucket=bucket)\n", + " # GetBucketLocation は us-east-1 を None(旧 API では '') で返す\n", + " return loc.get('LocationConstraint') or 'us-east-1'\n", + "\n", + "\n", + "def bucket_state(bucket, region):\n", + " c = s3_client(region)\n", + " listed = c.list_objects_v2(Bucket=bucket, MaxKeys=5)\n", + " ver = c.get_bucket_versioning(Bucket=bucket)\n", + " return {\n", + " 'bucket': bucket,\n", + " 'declared_region': region,\n", + " 'GetBucketLocation': bucket_region(bucket),\n", + " 'KeyCount': listed.get('KeyCount', 0),\n", + " 'sample_keys': [o['Key'] for o in listed.get('Contents', [])],\n", + " 'Versioning': ver.get('Status', 'Disabled'),\n", + " }\n", + "\n", + "\n", + "_states = [bucket_state(s3_bucket, s3_region_default)]\n", + "if run_apne1:\n", + " _states.append(bucket_state(s3_bucket_apne1, s3_region_explicit))\n", + "if run_versioned:\n", + " _states.append(bucket_state(s3_bucket_versioned, s3_region_default))\n", + "\n", + "for _s in _states:\n", + " assert _s['GetBucketLocation'] == _s['declared_region'], (\n", + " f\"{_s['bucket']} の所在は {_s['GetBucketLocation']} で、\"\n", + " f\"宣言した {_s['declared_region']} と違う。\"\n", + " 'WB はバケットの所在でエンドポイントを決めるので、ここが食い違うと'\n", + " 'IF-3 の判定にならない')\n", + "if run_versioned:\n", + " assert _states[-1]['Versioning'] == 'Enabled', \\\n", + " 's3_bucket_versioned のバージョニングが有効になっていない(S-4 の前提)'\n", + "\n", + "record('S-0-buckets.json', json.dumps(_states, indent=2, ensure_ascii=False))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "b1f23dc3-621e-4777-8128-b3d85acfdedc", + "metadata": {}, + "source": [ + "## S-1: 機関ストレージ登録 + 基本操作(IF-3 既定 / G-10)\n", + "\n", + "### Part 1: 管理者画面での機関ストレージ設定\n", + "\n", + "`s3_bucket`(`us-east-1`)を機関ストレージとして登録する。\n", + "WB は `GetBucketLocation` の結果でエンドポイントを決めるので、この登録が\n", + "「既定リージョン」の検証にあたる。" + ] + }, + { + "cell_type": "code", + "id": "09e4d977-5729-42d1-94e1-a007f458a448", + "metadata": { + "tags": [] + }, + "source": [ + "async def _step(page):\n", + " await page.goto(admin_rdm_url)\n", + "\n", + " await expect(page.locator('.login-logo')).to_be_visible(timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "5d6741e9-e627-401d-abc0-4ae338241995", + "metadata": {}, + "source": [ + "### ログイン情報を用いてGakuNin RDM管理者画面にログインする\n", + "\n", + "(IdPに関するログイン情報が与えられた場合、)\n", + "GakuNin Embeded DSのプルダウンを展開し、IdPリストから指定されたIdPを選択する。その後、アカウントのID/Passwordを入力して「Login」ボタンを押下する。\n", + "\n", + "(IdPが指定されていない場合、)\n", + "CASのログイン操作を実施する。" + ] + }, + { + "cell_type": "code", + "id": "f15d0739-31f4-4720-8392-c875e3065652", + "metadata": { + "tags": [] + }, + "source": [ + "import urllib.parse\n", + "\n", + "\n", + "async def admin_login(page):\n", + " email_field = page.locator('#id_email')\n", + " if await email_field.count() > 0 and await email_field.is_visible():\n", + " # Django メール/パスワードフォーム(enable_form=True の環境)\n", + " await email_field.fill(admin_username)\n", + " await page.locator('#id_password').fill(admin_password)\n", + " await page.locator('//form//button[@type=\"submit\"]').click()\n", + " else:\n", + " # entityID 直接指定で Shibboleth ログインを開始(Embedded DS バイパス)\n", + " if admin_idp_entity_id is None:\n", + " raise ValueError(\n", + " 'admin_idp_entity_id が未設定です。管理者ログインがメール/パスワード形式でない場合は '\n", + " '.config.yaml または本ノートブックのパラメータで対象環境のIdP entityIDを指定してください。'\n", + " )\n", + " base = admin_rdm_url.rstrip('/')\n", + " url = (base + '/Shibboleth.sso/Login'\n", + " '?entityID=' + urllib.parse.quote(admin_idp_entity_id, safe='')\n", + " + '&target=' + urllib.parse.quote(base + '/account/shib-login', safe=''))\n", + " await page.goto(url)\n", + " print('[1] after goto:', page.url)\n", + "\n", + " title = await page.title()\n", + " assert 'Unknown Identity Provider' not in title, (\n", + " f'SPが entityID を認識していません: {admin_idp_entity_id}')\n", + "\n", + " username = page.locator('#username')\n", + " j_username = page.locator('input[name=\"j_username\"]')\n", + " consent = page.locator('#_shib_idp_doNotRememberConsent')\n", + " logout_link = page.locator('//*[@href=\"/account/logout/\"]')\n", + "\n", + " # IdPログインフォーム / 同意画面 / 既ログイン のいずれかを待つ\n", + " await expect(username.or_(j_username).or_(consent).or_(logout_link).first\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + " if await username.count() > 0 and await username.is_visible():\n", + " await username.fill(admin_username)\n", + " await page.locator('#password').fill(admin_password)\n", + " await page.locator('//button[@type=\"submit\"] | //input[@type=\"submit\"]').first.click()\n", + " print('[2] after credential submit:', page.url)\n", + " elif await j_username.count() > 0 and await j_username.is_visible():\n", + " await j_username.fill(admin_username)\n", + " await page.locator('input[name=\"j_password\"]').fill(admin_password)\n", + " await page.locator('//button[@type=\"submit\"] | //input[@type=\"submit\"]').first.click()\n", + " print('[2] after credential submit (j_username):', page.url)\n", + " else:\n", + " print('[2] ログインフォームなし(既ログイン or 同意画面):', page.url)\n", + "\n", + " # ログイン失敗の検出(IdPのエラー表示)\n", + " await asyncio.sleep(2)\n", + " err = page.locator('.form-error, .output--error, p.form-element.form-error')\n", + " if await err.count() > 0 and await err.first.is_visible():\n", + " raise AssertionError('IdPログイン失敗: ' + (await err.first.text_content()).strip())\n", + "\n", + " # 同意画面(出ない場合はスキップ)\n", + " try:\n", + " await expect(consent).to_be_visible(timeout=15000)\n", + " await consent.click()\n", + " proceed = page.locator('//*[@name=\"_eventId_proceed\"]')\n", + " await expect(proceed).to_be_enabled()\n", + " await proceed.click()\n", + " print('[3] consent done:', page.url)\n", + " except AssertionError:\n", + " print('[3] 同意画面はスキップ:', page.url)\n", + "\n", + " # ログアウトボタンまたはlogoutリンクが表示されること(日本語/英語環境両対応)\n", + " logout_btn = page.locator('//*[contains(@class, \"btn-danger\") and contains(text(), \"ログアウト\")]')\n", + " logout_link2 = page.locator('//*[@href=\"/account/logout/\"]')\n", + " try:\n", + " await expect(logout_btn.or_(logout_link2)).to_be_visible(timeout=transition_timeout)\n", + " except AssertionError:\n", + " print('[4] 最終確認失敗。現在地:', page.url, '/', await page.title())\n", + " body = await page.evaluate('() => document.body.innerText.substring(0, 800)')\n", + " print(body)\n", + " raise\n", + " print('[4] 管理者画面ログイン成功:', page.url)\n", + "\n", + "\n", + "await run_pw(admin_login)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "2bdbe936-c7d1-46c5-b60f-4599fda96cd0", + "metadata": {}, + "source": [ + "### 機関ストレージの登録手順を関数にする\n", + "\n", + "S-1(既定リージョン)と S-2(`ap-northeast-1`)で同じ手順を 2 回使うので関数にする。\n", + "元の `テスト手順-管理者機能-S3-機関ストレージ.ipynb` の Part 1(セル 12〜28)を\n", + "そのまま関数へ移したもの。\n", + "\n", + "**この登録は上書きである**(セル [5] 参照)。`register_institutional_s3` を呼ぶたびに\n", + "その機関の機関ストレージ設定が置き換わる。" + ] + }, + { + "cell_type": "code", + "id": "0b5090b1-57bf-436a-83e5-bd56aebc3d5f", + "metadata": { + "tags": [] + }, + "source": [ + "async def goto_institutional_storage(page):\n", + " await page.goto(admin_rdm_url.rstrip('/') + '/custom_storage_location/institutional_storage/')\n", + " # サイドメニューの「機関ストレージ」をクリック(「機関ストレージのクォータ」と区別)\n", + " link = page.locator('//a[contains(@href, \"institutional_storage\") and not(contains(@href, \"quota\"))]').first\n", + " await expect(link).to_be_visible(timeout=transition_timeout)\n", + " await link.click()\n", + " # 機関ストレージ設定画面が表示されること(ラジオボタンの存在で確認)\n", + " await expect(page.locator('//input[@type=\"radio\"]').first).to_be_visible(timeout=transition_timeout)\n", + "\n", + "\n", + "async def select_institution(page):\n", + " \"\"\"機関のリストから target_organization を選ぶ。\n", + "\n", + " 単一機関の環境では機関リストが出ず、いきなり設定画面になる。その場合は\n", + " 何もしない(元ノートブックではこのセルはコメントアウトされていた)。\n", + " \"\"\"\n", + " if target_organization is None:\n", + " print('target_organization 未指定。機関リストの選択はスキップ')\n", + " return\n", + " link = page.locator(f'//a[text() = \"{target_organization}\"]')\n", + " if await link.count() == 0:\n", + " print(f'機関リストに \"{target_organization}\" が見つからない。既に設定画面とみなす')\n", + " return\n", + " await link.first.click()\n", + " await expect(page.locator('//h2[contains(text(), \"Institutional Storage\")]')\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + "\n", + "async def capture_institutional_storage_state(page, name):\n", + " \"\"\"登録前/後の選択状態を証跡に残す。後始末で戻すときの手がかりになる。\"\"\"\n", + " state = await page.evaluate(\n", + " '() => { var r = document.querySelector(\"input[name=options]:checked\");'\n", + " ' var n = document.getElementById(\"storage_name\");'\n", + " ' return {selected: r ? r.value : \"none\", storage_name: n ? n.value : null}; }')\n", + " await page.screenshot(path=evidence(name + '.png'), full_page=True)\n", + " record(name + '.txt', json.dumps(state, indent=2, ensure_ascii=False))\n", + " return state" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "83cbfc8b-4cb3-40ec-ae18-ff26c828f2d5", + "metadata": { + "tags": [] + }, + "source": [ + "async def register_institutional_s3(page, bucket, label):\n", + " \"\"\"Amazon S3 を機関ストレージとして登録する。**既存設定の上書きである。**\n", + "\n", + " :param bucket: 登録するバケット名\n", + " :param label: 証跡のファイル名に使う識別子(例 'S-1')\n", + " \"\"\"\n", + " await goto_institutional_storage(page)\n", + " await select_institution(page)\n", + "\n", + " # Amazon S3 のラジオボタンを選択\n", + " # (既定で別プロバイダ(例: s3compatsigv4)が選択済みの場合があるため、check + 選択状態を検証する)\n", + " radio = page.locator('//input[@type=\"radio\" and @value=\"s3\"]')\n", + " await expect(radio).to_be_visible(timeout=transition_timeout)\n", + " await radio.scroll_into_view_if_needed()\n", + " try:\n", + " await radio.check()\n", + " except Exception:\n", + " await radio.check(force=True)\n", + " await expect(radio).to_be_checked()\n", + " selected = await page.evaluate(\n", + " '() => { const r = document.querySelector(\"input[name=options]:checked\");'\n", + " ' return r ? r.value : \"none\"; }')\n", + " assert selected == 's3', f'Amazon S3 を選択できていません: {selected}'\n", + "\n", + " # storage_name フィールドの状態を確認し、必要なら入力する\n", + " storage_name = page.locator('#storage_name')\n", + " sn_visible = await storage_name.is_visible()\n", + " sn_enabled = await storage_name.is_enabled() if sn_visible else False\n", + " sn_value = await storage_name.input_value() if sn_visible else ''\n", + " print(f'storage_name: visible={sn_visible}, enabled={sn_enabled}, value=\"{sn_value}\"')\n", + " if sn_visible and sn_enabled and not sn_value.strip():\n", + " await storage_name.fill(institutional_storage_name)\n", + "\n", + " # Save(保存) ボタンをクリック\n", + " save_btn = page.locator('//button[@type=\"submit\" and contains(@class, \"btn-success\")]').first\n", + " await save_btn.scroll_into_view_if_needed()\n", + " await save_btn.click()\n", + " await asyncio.sleep(2)\n", + "\n", + " confirm_text = page.locator('#bbConfirmText')\n", + " s3_modal = page.locator('#s3_modal')\n", + "\n", + " # 確認ダイアログ(bootbox)が出た場合は確認文字列を入力して変更を確定する\n", + " try:\n", + " await expect(confirm_text).to_be_visible(timeout=10000)\n", + " confirm_strong = page.locator('//div[contains(@class, \"bootbox-body\")]//strong')\n", + " confirmation_string = await confirm_strong.text_content()\n", + " print(f'Confirmation string: {confirmation_string}')\n", + " await confirm_text.fill(confirmation_string)\n", + " await page.locator(\n", + " '//div[contains(@class, \"modal\") and contains(@class, \"bootbox\")]'\n", + " '//button[contains(@class, \"btn-danger\")]').click()\n", + " print('確認ダイアログを処理しました')\n", + " except Exception:\n", + " print('確認ダイアログは表示されませんでした')\n", + "\n", + " # Amazon S3 認証モーダルが開くこと。**開かなければ登録できていない。**\n", + " # 元ノートブックは「既設定とみなしてスキップ」していたが、本テストは\n", + " # バケットを差し替えること自体が手順なので、開かないなら失敗として扱う。\n", + " await expect(s3_modal).to_be_visible(timeout=20000)\n", + "\n", + " await page.locator('#s3_access_key').fill(s3_access_key)\n", + " await page.locator('#s3_secret_key').fill(s3_secret_key)\n", + " await page.locator('#s3_bucket').fill(bucket)\n", + " # keyupイベントを発火させてバリデーションをトリガー\n", + " await page.evaluate(\n", + " '() => { document.querySelectorAll(\"#s3_modal input\")'\n", + " '.forEach(el => el.dispatchEvent(new Event(\"keyup\", { bubbles: true }))); }')\n", + " await asyncio.sleep(1)\n", + "\n", + " # Connect(接続テスト)。Save が有効になるのが接続成功の証。\n", + " connect_btn = page.locator('#s3_connect')\n", + " await expect(connect_btn).to_be_enabled(timeout=transition_timeout)\n", + " await connect_btn.click()\n", + " save_modal_btn = page.locator('#s3_save')\n", + " await expect(save_modal_btn).to_be_enabled(timeout=transition_timeout)\n", + "\n", + " msg = await page.evaluate(\n", + " '() => document.getElementById(\"s3_message\")'\n", + " ' ? document.getElementById(\"s3_message\").innerText : \"no message element\"')\n", + " print('server message:', msg)\n", + " record(f'{label}-connect-message.txt',\n", + " f'bucket={bucket}\\nserver message={msg}\\n')\n", + "\n", + " await save_modal_btn.click()\n", + " await expect(s3_modal).to_be_hidden(timeout=transition_timeout)\n", + " await asyncio.sleep(2)\n", + " await capture_institutional_storage_state(page, f'{label}-institutional-storage-after')\n", + " print(f'{label}: {bucket} を機関ストレージとして登録した')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "26de3e91-b12a-4910-b220-0f709d596204", + "metadata": { + "tags": [] + }, + "source": [ + "# 登録前の状態を証跡に残してから、S-1 の登録を行う\n", + "async def _step(page):\n", + " await goto_institutional_storage(page)\n", + " await select_institution(page)\n", + " before = await capture_institutional_storage_state(\n", + " page, 'S-0-institutional-storage-before')\n", + " print('登録前:', before)\n", + "\n", + "await run_pw(_step)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "0b9796bd-d124-4fbe-8791-fd535c751bb7", + "metadata": { + "tags": [] + }, + "source": [ + "async def _step(page):\n", + " await register_institutional_s3(page, s3_bucket, 'S-1')\n", + "\n", + "await run_pw(_step)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "f9c7d513-ae2f-4550-a4e6-c2ba770711ac", + "metadata": {}, + "source": [ + "### 管理者画面からログアウトする\n", + "\n", + "ログアウトが完了すること" + ] + }, + { + "cell_type": "code", + "id": "1a87a923-797a-4314-9dfe-01e68c5855b5", + "metadata": { + "tags": [] + }, + "source": [ + "async def admin_logout(page):\n", + " logout_btn = page.locator('//*[contains(@class, \"btn-danger\") and contains(text(), \"ログアウト\")]')\n", + " logout_link = page.locator('//*[@href=\"/account/logout/\"]')\n", + " if await logout_btn.count() > 0:\n", + " await logout_btn.click()\n", + " else:\n", + " await logout_link.click()\n", + "\n", + " await expect(page.locator('.login-logo')).to_be_visible(timeout=transition_timeout)\n", + "\n", + "await run_pw(admin_logout)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "82741ed3-8713-49b6-8ad2-948d3f3cd2e5", + "metadata": {}, + "source": [ + "### Part 2: ユーザ画面でのプロジェクト作成・アップロード・ダウンロード\n", + "\n", + "`institutional_storage_name` のノードがファイルツリーに出ること(= 機関ストレージが\n", + "反映されたこと)、アップロードとダウンロードが通ることを確認する。\n", + "ダウンロードの 302 と presigned URL は、Playwright を閉じたあとに HAR を読んで\n", + "判定する(末尾の S-1(G-10) のセル)。" + ] + }, + { + "cell_type": "code", + "id": "ed1cfa85-5f84-4e97-a0a6-83ca701a4b42", + "metadata": { + "tags": [] + }, + "source": [ + "import scripts.grdm\n", + "importlib.reload(scripts.grdm)\n", + "\n", + "\n", + "async def _step(page):\n", + " await page.goto(rdm_url)\n", + " await page.wait_for_load_state('networkidle')\n", + "\n", + " consent_button = page.locator('//button[text() = \"同意する\"]')\n", + " if await consent_button.count() > 0 and await consent_button.is_visible():\n", + " await consent_button.click()\n", + " await page.wait_for_load_state('networkidle')\n", + "\n", + " await scripts.grdm.expect_anonymous_toppage(\n", + " page, idp_name_1, transition_timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "08bb087c-03b0-4e46-86d0-ed57501da49a", + "metadata": { + "tags": [] + }, + "source": [ + "async def _step(page):\n", + " await scripts.grdm.login(\n", + " page, idp_name_1, idp_username_1, idp_password_1, transition_timeout=transition_timeout\n", + " )\n", + " await expect(page.locator('//*[@data-test-create-project-modal-button]')\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "a355c5f4-653b-4c75-94a0-c0bde166754d", + "metadata": { + "tags": [] + }, + "source": [ + "async def open_project(page, project_name):\n", + " \"\"\"ダッシュボードから対象プロジェクトを開いてファイルタブを表示する。\"\"\"\n", + " await page.goto(rdm_url)\n", + " await expect(page.locator('//*[@data-test-create-project-modal-button]')\n", + " ).to_be_visible(timeout=transition_timeout)\n", + " await page.locator(\n", + " f'//*[@data-test-dashboard-item-title and text()=\"{project_name}\"]').click()\n", + " await expect(page.locator('#projectNavFiles')).to_be_visible(timeout=transition_timeout)\n", + "\n", + "\n", + "async def open_files_tab(page):\n", + " await page.locator('#projectNavFiles a').click()\n", + " await expect(page.locator('//*[@id = \"treeGrid\"]')).to_be_visible(timeout=transition_timeout)\n", + " await asyncio.sleep(3)\n", + "\n", + "\n", + "async def create_project_and_open_files(page, project_name):\n", + " await page.goto(rdm_url)\n", + " await expect(page.locator('//*[@data-test-create-project-modal-button]')\n", + " ).to_be_visible(timeout=transition_timeout)\n", + " await scripts.grdm.ensure_project_exists(page, project_name, transition_timeout)\n", + " await open_project(page, project_name)\n", + " await open_files_tab(page)\n", + "\n", + "\n", + "rdm_project_inst = f'{rdm_project_prefix}-institutional'\n", + "\n", + "async def _step(page):\n", + " await create_project_and_open_files(page, rdm_project_inst)\n", + "\n", + "await run_pw(_step)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "b8990a5f-0a92-4ab8-8a2f-684620021525", + "metadata": { + "tags": [] + }, + "source": [ + "# S-1 用のテストファイル。バケット名は書かない(証跡は S-0-params.txt にある)\n", + "s1_file_name = 'S-1-institutional.txt'\n", + "s1_file_path = os.path.join(work_dir, s1_file_name)\n", + "with open(s1_file_path, 'w') as f:\n", + " f.write('Institutional Storage Test - Amazon S3 (SigV4)\\n')\n", + " f.write(f'run_id: {run_id}\\n')\n", + " f.write(f'Created at: {datetime.now().isoformat()}\\n')\n", + "\n", + "print(s1_file_path, os.path.getsize(s1_file_path), 'bytes')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "a1797373-e3ad-4e0f-9637-30dfe9b92258", + "metadata": { + "tags": [] + }, + "source": [ + "async def _step(page):\n", + " await open_storage(page, institutional_storage_name)\n", + " await scripts.grdm.upload_file(page, s1_file_path)\n", + " await wait_for_upload_finished(page, s1_file_name)\n", + " await expect(page.locator(f'//*[contains(text(), \"{s1_file_name}\")]')\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "16a5f58c-f4f8-4a3f-bc49-1cb9ef10f7ea", + "metadata": { + "tags": [] + }, + "source": [ + "async def _step(page):\n", + " # ファイル詳細ページが表示されること(ファイル名ヘッダーの拡張子で確認)\n", + " await grdm.get_select_file_title_locator(page, s1_file_name).click(timeout=transition_timeout)\n", + " await asyncio.sleep(3)\n", + " body, ext = os.path.splitext(s1_file_name)\n", + " await expect(page.locator(f'//h2[contains(text(), \"{body}\")]//*[@id = \"file-ext\"]')\n", + " ).to_have_text(ext, timeout=transition_timeout)\n", + "\n", + "await run_pw(_step)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "08c07b56-8995-43e5-afb1-de4a05187ae7", + "metadata": {}, + "source": [ + "### S-1 (G-10): ダウンロードする\n", + "\n", + "ファイル詳細画面の「ダウンロード」を押し、ブラウザのダウンロードイベントを待つ。\n", + "\n", + "WB は `accept_url` が真のとき presigned URL を返し、ハンドラが 302 でそこへ飛ばす\n", + "(`waterbutler/server/api/v1/provider/metadata.py` の `get_file` →\n", + "`if isinstance(stream, str): return self.redirect(stream)`)。\n", + "**302 と `Location` の中身は HAR でしか取れない**ので、判定は末尾のセルで行う\n", + "(HAR は `finish_pw_context` のときに書き出される)。" + ] + }, + { + "cell_type": "code", + "id": "0bb9a41d-2d50-40ac-885f-083d8299f16b", + "metadata": { + "tags": [] + }, + "source": [ + "s1_download_path = None\n", + "\n", + "async def _step(page):\n", + " global s1_download_path\n", + " download_dir = os.path.join(work_dir, 'downloaded')\n", + " os.makedirs(download_dir, exist_ok=True)\n", + "\n", + " btn = page.locator(\n", + " '//i[contains(@class, \"fa-download\")]/..'\n", + " '/*[text() = \"ダウンロード\" or text() = \"Download\"]')\n", + " if await btn.count() == 0:\n", + " btn = page.locator('//a[contains(@href, \"?action=download\")]')\n", + " async with page.expect_download(timeout=transition_timeout * 3) as download_info:\n", + " await btn.first.click()\n", + " download = await download_info.value\n", + " s1_download_path = os.path.join(download_dir, download.suggested_filename)\n", + " await download.save_as(s1_download_path)\n", + " print('downloaded:', s1_download_path, os.path.getsize(s1_download_path), 'bytes')\n", + "\n", + "await run_pw(_step)\n", + "\n", + "with open(s1_file_path) as _a, open(s1_download_path) as _b:\n", + " assert _a.read() == _b.read(), 'ダウンロードした内容がアップロードしたものと違う'\n", + "print('S-1: アップロードした内容と一致')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "ea73d766-fed9-4bee-b524-48db3860e60b", + "metadata": { + "tags": [] + }, + "source": [ + "# S-1 の判定: WB 応答に 4xx/5xx も署名系の文言も無いこと\n", + "_bad = wb_failures()\n", + "record('S-1-verdict.txt',\n", + " f'wb_responses={len(wb_responses)}\\n'\n", + " f'failures={len(_bad)}\\n'\n", + " + _redact(json.dumps(_bad, indent=2, ensure_ascii=False)))\n", + "dump_wb_responses('S-1-wb-responses.json')\n", + "assert not _bad, 'S-1 で WB がエラーを返している(上の S-1-verdict.txt を見ること)'" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "995ee7ff-09ac-483d-a3df-b19b9d61a53f", + "metadata": {}, + "source": [ + "## S-2: 明示リージョン(`ap-northeast-1`)(IF-3)\n", + "\n", + "`s3_bucket_apne1` を同じ機関に**上書き**登録し、アップロードとダウンロードを\n", + "やり直す。WB は `GetBucketLocation` で `ap-northeast-1` を得て\n", + "`https://s3.ap-northeast-1.amazonaws.com` を署名対象ホストにする。\n", + "\n", + "**判定**: WB 応答に 4xx/5xx が無く、本文に `SignatureDoesNotMatch` /\n", + "`AuthorizationHeaderMalformed` / `PermanentRedirect` を含まないこと。\n", + "`PermanentRedirect` は「署名したホストと送ったホストが食い違っている」ときに\n", + "S3 が返すコードで、エンドポイント固定が壊れたときの典型的な症状である。\n", + "\n", + "別プロジェクトを作って実施する。S-1 のプロジェクトは `us-east-1` のバケットに\n", + "本体を置いた版を持っており、登録を差し替えたあとに同じプロジェクトを使うと\n", + "どちらのバケットを見ているのか証跡上あいまいになるため。" + ] + }, + { + "cell_type": "code", + "id": "eef6d10f-a1bd-4740-b80a-48575f4feea9", + "metadata": { + "tags": [] + }, + "source": [ + "rdm_project_apne1 = f'{rdm_project_prefix}-apne1'\n", + "\n", + "if run_apne1:\n", + " async def _step(page):\n", + " await page.goto(admin_rdm_url)\n", + " await admin_login(page)\n", + " await register_institutional_s3(page, s3_bucket_apne1, 'S-2')\n", + " await admin_logout(page)\n", + "\n", + " await run_pw(_step)\n", + "else:\n", + " print('run_apne1=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "4328ad4d-0059-4ba0-97b9-1d36fe49f2ee", + "metadata": { + "tags": [] + }, + "source": [ + "s2_file_name = 'S-2-apne1.txt'\n", + "s2_file_path = os.path.join(work_dir, s2_file_name)\n", + "\n", + "if run_apne1:\n", + " with open(s2_file_path, 'w') as f:\n", + " f.write('Explicit region test - ap-northeast-1 (SigV4)\\n')\n", + " f.write(f'run_id: {run_id}\\n')\n", + " f.write(f'Created at: {datetime.now().isoformat()}\\n')\n", + "\n", + " async def _step(page):\n", + " await create_project_and_open_files(page, rdm_project_apne1)\n", + " await open_storage(page, institutional_storage_name)\n", + " await scripts.grdm.upload_file(page, s2_file_path)\n", + " await wait_for_upload_finished(page, s2_file_name)\n", + " await expect(page.locator(f'//*[contains(text(), \"{s2_file_name}\")]')\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + " await run_pw(_step)\n", + "else:\n", + " print('run_apne1=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "e21c044c-724a-404a-83f3-883b4dad62ed", + "metadata": { + "tags": [] + }, + "source": [ + "s2_download_path = None\n", + "\n", + "if run_apne1:\n", + " async def _step(page):\n", + " global s2_download_path\n", + " await grdm.get_select_file_title_locator(page, s2_file_name).click(\n", + " timeout=transition_timeout)\n", + " await asyncio.sleep(3)\n", + " download_dir = os.path.join(work_dir, 'downloaded')\n", + " os.makedirs(download_dir, exist_ok=True)\n", + " btn = page.locator(\n", + " '//i[contains(@class, \"fa-download\")]/..'\n", + " '/*[text() = \"ダウンロード\" or text() = \"Download\"]')\n", + " if await btn.count() == 0:\n", + " btn = page.locator('//a[contains(@href, \"?action=download\")]')\n", + " async with page.expect_download(timeout=transition_timeout * 3) as download_info:\n", + " await btn.first.click()\n", + " download = await download_info.value\n", + " s2_download_path = os.path.join(download_dir, download.suggested_filename)\n", + " await download.save_as(s2_download_path)\n", + "\n", + " await run_pw(_step)\n", + "\n", + " with open(s2_file_path) as _a, open(s2_download_path) as _b:\n", + " assert _a.read() == _b.read(), 'S-2 のダウンロード内容が一致しない'\n", + " print('S-2: アップロードした内容と一致')\n", + "else:\n", + " print('run_apne1=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "0253fceb-e70f-4a5b-87dc-ccda05915ff4", + "metadata": { + "tags": [] + }, + "source": [ + "if run_apne1:\n", + " # バケットに実体が入ったことを boto3 でも確かめる(osfstorage は内容アドレスで\n", + " # 置くのでキー名はハッシュになる。件数と所在だけ見る)\n", + " _c = s3_client(s3_region_explicit)\n", + " _listed = _c.list_objects_v2(Bucket=s3_bucket_apne1, MaxKeys=10)\n", + " _bad = wb_failures()\n", + " _verdict = {\n", + " 'bucket': s3_bucket_apne1,\n", + " 'GetBucketLocation': bucket_region(s3_bucket_apne1),\n", + " 'KeyCount': _listed.get('KeyCount', 0),\n", + " 'wb_responses': len(wb_responses),\n", + " 'failures': _bad,\n", + " }\n", + " record('S-2-verdict.json', _redact(json.dumps(_verdict, indent=2, ensure_ascii=False)))\n", + " dump_wb_responses('S-2-wb-responses.json')\n", + " assert not _bad, 'S-2 で WB がエラーを返している(署名エラーの可能性)'\n", + " assert _listed.get('KeyCount', 0) > 0, \\\n", + " f'{s3_bucket_apne1} にオブジェクトが無い。別のバケットに書かれている'\n", + " print('S-2: 署名エラーなし。ap-northeast-1 のバケットに実体が入っている')\n", + "else:\n", + " print('run_apne1=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "10d72381-d159-46a4-a31b-d48ec6cce261", + "metadata": {}, + "source": [ + "## S-3 〜 S-5 の前に: プロジェクトに **S3 アドオン**を接続する\n", + "\n", + "セル [5] の表のとおり、一覧・削除・intra copy/move は機関ストレージ(= `osfstorage`\n", + "経由)では `s3` プロバイダに届かない。ここから先はプロジェクトに\n", + "`Amazon S3` アドオンを接続し、ファイルツリーの `Amazon S3` ノードで操作する。\n", + "\n", + "接続手順は `取りまとめ-S3共通.ipynb` のセル 27〜45 と同じ。関数にして\n", + "S-3/S-5 用(通常バケット)と S-4 用(バージョニング有効バケット)で 2 回使う。" + ] + }, + { + "cell_type": "code", + "id": "7e1d1f33-26f9-4e3e-bda7-fec7d9cdd4f2", + "metadata": { + "tags": [] + }, + "source": [ + "async def connect_s3_addon(page, project_name, bucket):\n", + " \"\"\"プロジェクトに S3 アドオンを接続し、ファイルページを開く。\n", + "\n", + " 取りまとめ-S3共通.ipynb のセル 27〜45 と同じ手順。\n", + " アカウント(アクセスキー)はユーザー単位に保持されるので、2 回目以降は\n", + " 「アカウントに接続する」ではなく既存アカウントの選択になる。両方に対応する。\n", + " \"\"\"\n", + " await open_project(page, project_name)\n", + "\n", + " # アドオンタブ → 有効にする → 確認\n", + " await page.locator('//a[text() = \"アドオン\"]').click()\n", + " enable_link = page.locator(\n", + " f'//div[@full_name = \"{addon_storage_name}\"]//descendant::a[text() = \"有効にする\"]')\n", + " await expect(enable_link).to_be_visible(timeout=transition_timeout)\n", + " await enable_link.click()\n", + " confirm = page.locator('//button[@data-bb-handler = \"confirm\"]')\n", + " await expect(confirm).to_be_visible(timeout=transition_timeout)\n", + " await confirm.click()\n", + " await asyncio.sleep(2)\n", + "\n", + " # 資格情報の入力欄が出たら入力して保存する(初回のみ)\n", + " input_creds = page.locator(f'#{addon_storage_id}InputCredentials')\n", + " connect_link = page.locator(\n", + " f'//img[@src = \"/static/addons/{addon_storage_id}/comicon.png\"]/..'\n", + " f'//a[contains(text(), \"アカウントに接続する\")]')\n", + " import_link = page.locator('//a[contains(text(), \"プロフィールからアカウントをインポート\")]')\n", + "\n", + " if await connect_link.count() > 0:\n", + " await connect_link.first.click()\n", + " await expect(input_creds.locator('.btn-success')).to_be_enabled(\n", + " timeout=transition_timeout)\n", + " await asyncio.sleep(1)\n", + " await page.locator(\n", + " f'//*[@id = \"{addon_storage_id}InputCredentials\"]//input[@id = \"access_key\"]'\n", + " ).fill(s3_access_key)\n", + " await page.locator(\n", + " f'//*[@id = \"{addon_storage_id}InputCredentials\"]//input[@id = \"secret_key\"]'\n", + " ).fill(s3_secret_key)\n", + " await input_creds.locator('.btn-success').click()\n", + " elif await import_link.count() > 0:\n", + " await import_link.first.click()\n", + " await asyncio.sleep(2)\n", + "\n", + " # 接続 → バケット選択 → 保存\n", + " connect_btn = page.locator('//button[text() = \"接続\"]')\n", + " await expect(connect_btn).to_be_enabled(timeout=transition_timeout)\n", + " await asyncio.sleep(1)\n", + " await connect_btn.click()\n", + " await expect(page.locator('//button[@id = \"newBucket\"]')).to_be_visible(\n", + " timeout=transition_timeout)\n", + " await page.locator(\n", + " f'//span[text() = \"{bucket}\"]/../../..//input[@type = \"radio\"]').click()\n", + " # delay を入れないと「正常にリンクされました」が出ない(取りまとめ NB セル 43 の注記)\n", + " await asyncio.sleep(1)\n", + " await page.locator(\n", + " f'//*[@class = \"{addon_storage_id}-confirm-selection\"]//input[@value = \"保存\"]').click()\n", + " await expect(page.locator(\n", + " f'//*[@id = \"{addon_storage_id}Scope\"]/*[contains(@class, \"help-block\")]'\n", + " f'//*[contains(text(), \"正常にリンクされました\")]')).to_be_visible(timeout=30000)\n", + "\n", + " # ファイルページへ\n", + " try:\n", + " await page.get_by_role('link', name='ファイルページ').click()\n", + " except Exception:\n", + " await page.locator('//*[@id = \"projectNavFiles\"]//a').click()\n", + " await expect(page.locator('//*[@id = \"treeGrid\"]')).to_be_visible(\n", + " timeout=transition_timeout)\n", + " await expect(grdm.get_select_expanded_storage_title_locator(page, addon_storage_name)\n", + " ).to_be_visible(timeout=transition_timeout)\n", + " await asyncio.sleep(2)\n", + " print(f'{project_name}: {addon_storage_name} アドオンを接続した')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "08424ce4-af34-46fa-897f-afbe27b8c15c", + "metadata": { + "tags": [] + }, + "source": [ + "def listing_pages(entries=None, path_contains=None):\n", + " \"\"\"WB 応答から「フォルダ一覧(?meta=)」だけを抜き、next_token の連鎖に直す。\n", + "\n", + " - 要求側の next_token は URL のクエリから読む\n", + " (fangorn.js `_fangornResolveLazyLoad` が `next_token` を付ける)\n", + " - 応答側の next_token は本文のトップレベル `next_token`\n", + " (waterbutler/server/api/v1/provider/metadata.py `get_folder`)\n", + " \"\"\"\n", + " from urllib.parse import urlparse, parse_qs\n", + " entries = wb_responses if entries is None else entries\n", + " pages = []\n", + " for e in entries:\n", + " if e['method'] != 'GET' or 'meta=' not in e['url']:\n", + " continue\n", + " parsed = urlparse(e['url'])\n", + " if path_contains and path_contains not in parsed.path:\n", + " continue\n", + " q = parse_qs(parsed.query)\n", + " page = {\n", + " 'url_path': parsed.path,\n", + " 'request_next_token': q.get('next_token', [None])[0],\n", + " 'status': e['status'],\n", + " 'at': e['at'],\n", + " }\n", + " try:\n", + " body = json.loads(e['body'])\n", + " except Exception:\n", + " page['parse_error'] = True\n", + " page['names'] = []\n", + " page['response_next_token'] = None\n", + " else:\n", + " data = body.get('data', []) if isinstance(body, dict) else []\n", + " page['count'] = len(data)\n", + " page['names'] = [d.get('attributes', {}).get('name') for d in data]\n", + " page['response_next_token'] = (\n", + " body.get('next_token') if isinstance(body, dict) else None)\n", + " pages.append(page)\n", + " return pages" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "31e707a6-9c26-4d12-ba47-0bd3a8bc14f2", + "metadata": {}, + "source": [ + "## S-3: 1000 件超フォルダの一覧(P-6 / R-6)\n", + "\n", + "S3 の ListObjectsV2 は 1 回で最大 1000 件しか返さない。WB は\n", + "`get_folder_metadata(..., next_token=...)` で続きを取り、ハンドラが応答の\n", + "末尾に `next_token` を載せる。fangorn はスクロールが底に着くたびに\n", + "`next_token` 付きで再要求する(`fetchData` / `handleScroll`)。\n", + "\n", + "**判定**(指示 §1.5):\n", + "\n", + "1. `toomany/` の中身が `0001` 〜 `{toomany_count}` まで**重複・欠落なく**表示される\n", + "2. 一覧応答の `next_token` が連鎖し、**最終ページで空**になる\n", + "\n", + "`toomany/` の中身は boto3 の `put_object`(空本文)で作る。UI からの 1500 回\n", + "アップロードは時間がかかるうえ、検証したいのは一覧のページングだけなので\n", + "直接置く。" + ] + }, + { + "cell_type": "code", + "id": "b399f054-00bd-409c-a436-7defc37e103d", + "metadata": { + "tags": [] + }, + "source": [ + "# S-3: toomany/0001 〜 NNNN を boto3 で作る\n", + "s3_addon_project = f'{rdm_project_prefix}-addon'\n", + "toomany_names = ['{0:04d}'.format(i + 1) for i in range(toomany_count)]\n", + "\n", + "if run_toomany:\n", + " _c = s3_client(s3_region_default)\n", + " _existing = set()\n", + " _paginator = _c.get_paginator('list_objects_v2')\n", + " for _p in _paginator.paginate(Bucket=s3_bucket, Prefix=toomany_prefix):\n", + " for _o in _p.get('Contents', []):\n", + " _existing.add(_o['Key'])\n", + "\n", + " _t0 = time.time()\n", + " _made = 0\n", + " for _name in toomany_names:\n", + " _key = toomany_prefix + _name\n", + " if _key in _existing:\n", + " continue\n", + " _c.put_object(Bucket=s3_bucket, Key=_key, Body=b'')\n", + " _made += 1\n", + " if _made % 250 == 0:\n", + " print(f' {_made} 件 ({time.time() - _t0:.0f}s)')\n", + "\n", + " # 数えなおす(put_object は結果整合ではないが、念のため一覧で確認する)\n", + " _keys = []\n", + " for _p in _paginator.paginate(Bucket=s3_bucket, Prefix=toomany_prefix):\n", + " for _o in _p.get('Contents', []):\n", + " _keys.append(_o['Key'])\n", + " record('S-3-seed.txt',\n", + " f'prefix={toomany_prefix}\\n'\n", + " f'created={_made}\\n'\n", + " f'total_in_bucket={len(_keys)}\\n'\n", + " f'elapsed={time.time() - _t0:.0f}s\\n')\n", + " assert len(_keys) == toomany_count, \\\n", + " f'{toomany_prefix} の件数が {len(_keys)} で {toomany_count} と違う'\n", + "else:\n", + " print('run_toomany=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "2d4cc295-1767-4808-bc88-9f7d286220ab", + "metadata": { + "tags": [] + }, + "source": [ + "# S-3 / S-5 用のプロジェクトを作り、S3 アドオン(通常バケット)を接続する\n", + "if run_toomany or run_intra:\n", + " async def _step(page):\n", + " await create_project_and_open_files(page, s3_addon_project)\n", + " await connect_s3_addon(page, s3_addon_project, s3_bucket)\n", + "\n", + " await run_pw(_step)\n", + " dump_wb_responses('S-3-addon-connect.json')\n", + "else:\n", + " print('run_toomany / run_intra がどちらも False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "49c47126-c214-418d-84de-893f83330fd1", + "metadata": {}, + "source": [ + "### `toomany` フォルダを開き、底までスクロールして追加読み込みさせる\n", + "\n", + "`handleScroll` はスクロールが最終行に達したときにだけ `fetchData` を呼ぶ。\n", + "`#tb-tbody` を底まで送る操作を、読み込み件数が増えなくなるまで繰り返す。" + ] + }, + { + "cell_type": "code", + "id": "5e9d1e31-4687-460c-99a7-df13c67ffd5c", + "metadata": { + "tags": [] + }, + "source": [ + "toomany_pages = []\n", + "\n", + "if run_toomany:\n", + " async def _step(page):\n", + " global toomany_pages\n", + " wb_responses.clear()\n", + " folder = toomany_prefix.rstrip('/')\n", + " await grdm.get_select_folder_title_locator(page, folder).click()\n", + " await asyncio.sleep(3)\n", + "\n", + " stable = 0\n", + " last = -1\n", + " for i in range(600):\n", + " await page.evaluate(\n", + " \"() => { var e = document.querySelector('#tb-tbody');\"\n", + " \" e.scrollTop = e.scrollHeight; }\")\n", + " await asyncio.sleep(0.7)\n", + " loaded = sum(p.get('count', 0) for p in listing_pages(path_contains=folder))\n", + " if loaded == last:\n", + " stable += 1\n", + " else:\n", + " stable = 0\n", + " last = loaded\n", + " if loaded >= toomany_count and stable >= 3:\n", + " break\n", + " if stable >= 15:\n", + " print(f'[!] {loaded} 件で増えなくなった(scroll {i} 回)')\n", + " break\n", + " toomany_pages = listing_pages(path_contains=folder)\n", + " print(f'一覧応答 {len(toomany_pages)} 本、合計 '\n", + " f'{sum(p.get(\"count\", 0) for p in toomany_pages)} 件')\n", + "\n", + " await run_pw(_step)\n", + "else:\n", + " print('run_toomany=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "7516a48e-27dd-4ff9-a3e2-46f9cd93afd5", + "metadata": { + "tags": [] + }, + "source": [ + "# S-3 の判定\n", + "if run_toomany:\n", + " _names = []\n", + " for _p in toomany_pages:\n", + " _names.extend([n for n in _p['names'] if n])\n", + " _dups = sorted({n for n in _names if _names.count(n) > 1}) if len(_names) < 4000 else []\n", + " _missing = sorted(set(toomany_names) - set(_names))\n", + " _extra = sorted(set(_names) - set(toomany_names))\n", + " _last = toomany_pages[-1] if toomany_pages else None\n", + "\n", + " _verdict = {\n", + " 'expected': toomany_count,\n", + " 'listed_unique': len(set(_names)),\n", + " 'listed_total': len(_names),\n", + " 'pages': len(toomany_pages),\n", + " 'page_counts': [p.get('count') for p in toomany_pages],\n", + " 'token_chain': [\n", + " {'request_next_token': p['request_next_token'],\n", + " 'response_next_token': p['response_next_token'],\n", + " 'count': p.get('count')}\n", + " for p in toomany_pages],\n", + " 'duplicates': _dups,\n", + " 'missing': _missing[:50],\n", + " 'missing_count': len(_missing),\n", + " 'unexpected': _extra[:50],\n", + " 'last_response_next_token': _last['response_next_token'] if _last else None,\n", + " 'statuses': sorted({p['status'] for p in toomany_pages}),\n", + " }\n", + " record('S-3-listing-pages.json',\n", + " _redact(json.dumps(_verdict, indent=2, ensure_ascii=False)))\n", + " dump_wb_responses('S-3-wb-responses.json')\n", + "\n", + " assert _verdict['statuses'] and max(_verdict['statuses']) < 400, \\\n", + " f'一覧応答にエラーがある: {_verdict[\"statuses\"]}'\n", + " assert not _missing, f'一覧に出てこない名前が {len(_missing)} 件ある: {_missing[:10]}'\n", + " assert not _extra, f'期待しない名前が出ている: {_extra[:10]}'\n", + " assert not _dups, f'一覧に重複がある: {_dups[:10]}'\n", + " assert len(toomany_pages) >= 2, \\\n", + " '一覧応答が 1 本しかない。1000 件超のページングが起きていない'\n", + " assert _last['response_next_token'] in (None, ''), \\\n", + " f'最終ページに next_token が残っている: {_last[\"response_next_token\"]!r}'\n", + " print(f'S-3: {toomany_count} 件を {len(toomany_pages)} 本の応答で重複・欠落なく取得。'\n", + " '最終ページの next_token は空')\n", + "else:\n", + " print('run_toomany=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "b1c61ef9-baea-426b-8ecb-e7c1014d7962", + "metadata": {}, + "source": [ + "### (任意) 全 1500 行を 1 件ずつ画面でたどる\n", + "\n", + "`取りまとめ-S3共通.ipynb` セル 135 と同じ確認。treebeard は表示範囲の行しか\n", + "DOM に置かない(仮想スクロール)ため、**DOM の行数を数えても総数にはならない**。\n", + "そこで先頭から順に `scroll_into_view_if_needed` でたどり、全件が画面に出せる\n", + "ことを確かめる。1500 回の Playwright 呼び出しになるので数分かかる。\n", + "`toomany_walk_rows = False` にすると飛ばせる(判定は上のセルの応答解析で足りる)。" + ] + }, + { + "cell_type": "code", + "id": "e08b315a-5c35-4ef2-a942-5cbfa16b0fbf", + "metadata": { + "tags": [] + }, + "source": [ + "toomany_walk_rows = True\n", + "\n", + "if run_toomany and toomany_walk_rows:\n", + " async def _step(page):\n", + " _t0 = time.time()\n", + " for i, name in enumerate(toomany_names):\n", + " try:\n", + " await grdm.get_select_file_extension_locator(\n", + " page, name).scroll_into_view_if_needed(timeout=transition_timeout)\n", + " except Exception as e:\n", + " raise AssertionError(\n", + " f'{i + 1} 件目 \"{name}\" を画面に出せない: {e!r}')\n", + " await page.evaluate(\n", + " \"() => document.querySelector('#tb-tbody').scrollBy(0, 40)\")\n", + " if (i + 1) % 250 == 0:\n", + " print(f' {i + 1} 件目まで確認 ({time.time() - _t0:.0f}s)')\n", + " _rows = await page.locator('//*[@id = \"tb-tbody\"]//*[contains(@class, \"tb-row\")]').count()\n", + " record('S-3-row-walk.txt',\n", + " f'walked={len(toomany_names)}\\n'\n", + " f'dom_rows_at_end={_rows} # 仮想スクロールのため総数ではない\\n'\n", + " f'elapsed={time.time() - _t0:.0f}s\\n')\n", + "\n", + " await run_pw(_step)\n", + "else:\n", + " print('スキップ(run_toomany=False または toomany_walk_rows=False)')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "de49bb7d-cdcb-4848-8e83-ce6869d3834f", + "metadata": {}, + "source": [ + "## S-4: バージョニング有効バケットでの削除(V-1)\n", + "\n", + "バージョニングが有効なバケットでは `DeleteObject` は実体を消さず\n", + "**DeleteMarker を積む**。PR #77 の仕様は「UI の削除でその Key の\n", + "`Versions` も `DeleteMarkers` も残さない」こと。\n", + "\n", + "手順: 同名ファイルを UI から 3 回アップロード(上書き)→\n", + "`list_object_versions` で 3 版以上あることを確認 → UI から削除 →\n", + "`list_object_versions` を取り直して **両方 0** であることを確認。" + ] + }, + { + "cell_type": "code", + "id": "47de8ac1-f1e9-40fe-aef3-c1364b5506da", + "metadata": { + "tags": [] + }, + "source": [ + "s4_project = f'{rdm_project_prefix}-versioned'\n", + "s4_file_name = 'S-4-versioned.txt'\n", + "s4_file_path = os.path.join(work_dir, s4_file_name)\n", + "\n", + "\n", + "def list_versions(bucket, prefix, region=None):\n", + " \"\"\"当該 Key の Versions / DeleteMarkers を全ページ集める。\"\"\"\n", + " c = s3_client(region or s3_region_default)\n", + " versions, markers = [], []\n", + " kwargs = {'Bucket': bucket, 'Prefix': prefix}\n", + " while True:\n", + " r = c.list_object_versions(**kwargs)\n", + " versions.extend({'Key': v['Key'], 'VersionId': v['VersionId'],\n", + " 'IsLatest': v['IsLatest'], 'Size': v.get('Size')}\n", + " for v in r.get('Versions', []))\n", + " markers.extend({'Key': m['Key'], 'VersionId': m['VersionId'],\n", + " 'IsLatest': m['IsLatest']}\n", + " for m in r.get('DeleteMarkers', []))\n", + " if not r.get('IsTruncated'):\n", + " break\n", + " kwargs['KeyMarker'] = r.get('NextKeyMarker')\n", + " kwargs['VersionIdMarker'] = r.get('NextVersionIdMarker')\n", + " return {'prefix': prefix, 'Versions': versions, 'DeleteMarkers': markers,\n", + " 'version_count': len(versions), 'delete_marker_count': len(markers)}\n", + "\n", + "\n", + "if run_versioned:\n", + " async def _step(page):\n", + " await create_project_and_open_files(page, s4_project)\n", + " await connect_s3_addon(page, s4_project, s3_bucket_versioned)\n", + "\n", + " await run_pw(_step)\n", + " dump_wb_responses('S-4-addon-connect.json')\n", + "else:\n", + " print('run_versioned=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "29cd858b-9a2d-4865-bd1e-cda717948399", + "metadata": { + "tags": [] + }, + "source": [ + "if run_versioned:\n", + " async def _step(page):\n", + " await open_storage(page, addon_storage_name)\n", + " for i in range(3):\n", + " with open(s4_file_path, 'w') as f:\n", + " f.write(f'S-4 versioned delete test / revision {i + 1}\\n')\n", + " f.write(f'run_id: {run_id}\\n')\n", + " f.write(f'at: {datetime.now().isoformat()}\\n')\n", + " f.write('x' * (100 * (i + 1)))\n", + " await clear_growls(page)\n", + " await grdm.upload_file(page, s4_file_path)\n", + " await wait_for_upload_finished(page, s4_file_name)\n", + " # 2 回目以降は「上書きしますか」の確認が出る\n", + " keep = page.locator('//button[contains(text(), \"置き換える\")'\n", + " ' or contains(text(), \"Replace\")]')\n", + " if await keep.count() > 0 and await keep.first.is_visible():\n", + " await keep.first.click()\n", + " await wait_for_upload_finished(page, s4_file_name)\n", + " await asyncio.sleep(2)\n", + " print(f' {i + 1} 回目のアップロード完了')\n", + " await expect(grdm.get_select_file_title_locator(page, s4_file_name)\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + " await run_pw(_step)\n", + "else:\n", + " print('run_versioned=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "50476bda-369f-4896-8cd0-b11a0fba5acf", + "metadata": { + "tags": [] + }, + "source": [ + "if run_versioned:\n", + " s4_before = list_versions(s3_bucket_versioned, s4_file_name)\n", + " record('S-4-versions-before.json',\n", + " _redact(json.dumps(s4_before, indent=2, ensure_ascii=False)))\n", + " assert s4_before['version_count'] >= 3, (\n", + " f'{s4_file_name} の版が {s4_before[\"version_count\"]} 個しかない。'\n", + " 'バージョニングが効いていないか、上書きになっていない')\n", + " print(f'S-4: 削除前 Versions={s4_before[\"version_count\"]} '\n", + " f'DeleteMarkers={s4_before[\"delete_marker_count\"]}')\n", + "else:\n", + " print('run_versioned=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "da037810-4846-411f-8c98-647e613ccc0e", + "metadata": { + "tags": [] + }, + "source": [ + "# (任意) X9 の実機版: 同一 Key に 1001 版を作ってから削除する\n", + "if run_versioned and run_versioned_bulk:\n", + " _c = s3_client(s3_region_default)\n", + " _t0 = time.time()\n", + " _target = 1001\n", + " _have = list_versions(s3_bucket_versioned, s4_file_name)['version_count']\n", + " for i in range(_have, _target):\n", + " _c.put_object(Bucket=s3_bucket_versioned, Key=s4_file_name,\n", + " Body=f'bulk {i}\\n'.encode())\n", + " if (i + 1) % 200 == 0:\n", + " print(f' {i + 1} 版 ({time.time() - _t0:.0f}s)')\n", + " s4_before = list_versions(s3_bucket_versioned, s4_file_name)\n", + " record('S-4-versions-before.json',\n", + " _redact(json.dumps(s4_before, indent=2, ensure_ascii=False)))\n", + " print(f'S-4 bulk: Versions={s4_before[\"version_count\"]}')\n", + "else:\n", + " print('run_versioned_bulk=False のためスキップ(既定)')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "11b8417d-e3c1-40db-9404-b4c4ef502ef5", + "metadata": { + "tags": [] + }, + "source": [ + "# UI から削除する\n", + "if run_versioned:\n", + " async def _step(page):\n", + " await grdm.get_select_file_extension_locator(page, s4_file_name).click()\n", + " delete_btn = page.locator(\n", + " '//i[contains(@class, \"fa-trash\")]/../*[text() = \"削除\"]')\n", + " await expect(delete_btn).to_be_enabled(timeout=transition_timeout)\n", + " await clear_growls(page)\n", + " wb_responses.clear()\n", + " await delete_btn.click()\n", + " confirm = page.locator(\n", + " '//*[@id = \"tb-tbody\"]//*[@class = \"modal-content\"]'\n", + " '//*[contains(@class, \"btn-danger\")]')\n", + " await expect(confirm).to_be_enabled(timeout=transition_timeout)\n", + " await asyncio.sleep(1)\n", + " await confirm.click()\n", + " await expect(grdm.get_select_file_title_locator(page, s4_file_name)\n", + " ).to_have_count(0, timeout=transition_timeout * 3)\n", + " print('growl:', await growl_messages(page))\n", + "\n", + " await run_pw(_step)\n", + " # 削除は非同期ではないが、S3 の一覧が追いつくまで少し待つ\n", + " time.sleep(5)\n", + "else:\n", + " print('run_versioned=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "e0ad04d1-a78d-4a86-97f0-bf5ae7ba3457", + "metadata": { + "tags": [] + }, + "source": [ + "if run_versioned:\n", + " s4_after = list_versions(s3_bucket_versioned, s4_file_name)\n", + " _bad = wb_failures()\n", + " record('S-4-versions-after.json',\n", + " _redact(json.dumps({'after': s4_after,\n", + " 'wb_failures': _bad,\n", + " 'before_version_count': s4_before['version_count'],\n", + " 'before_delete_marker_count':\n", + " s4_before['delete_marker_count']},\n", + " indent=2, ensure_ascii=False)))\n", + " dump_wb_responses('S-4-wb-responses.json')\n", + " assert not _bad, 'S-4 の削除で WB がエラーを返している'\n", + " assert s4_after['version_count'] == 0, (\n", + " f'削除後も Versions が {s4_after[\"version_count\"]} 個残っている。'\n", + " 'PR #77 の仕様(全版を消す)を満たしていない')\n", + " assert s4_after['delete_marker_count'] == 0, (\n", + " f'削除後に DeleteMarkers が {s4_after[\"delete_marker_count\"]} 個ある。'\n", + " 'DeleteMarker を残す実装になっている')\n", + " print('S-4: Versions / DeleteMarkers ともに 0。V-1 を満たす')\n", + "else:\n", + " print('run_versioned=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "dbe917c0-2280-4856-884b-88a642acffcc", + "metadata": {}, + "source": [ + "## S-5: intra copy / move(I-2)\n", + "\n", + "同一プロジェクト・同一ストレージ内の移動とコピーは、WB では\n", + "`intra_move` / `intra_copy` になり、S3 では `CopyObject`(SigV4 署名)として\n", + "出る。ここが壊れると `SignatureDoesNotMatch` か、`x-amz-copy-source` の\n", + "署名不一致で失敗する。\n", + "\n", + "fangorn ではドラッグ&ドロップで行う。`getCopyMode`(fangorn.js)が\n", + "\n", + "```\n", + "if (folder.data.isPointer || altKey || !canMove) { return 'copy'; }\n", + "return 'move';\n", + "```\n", + "\n", + "としているので、**素のドラッグ = 移動、Alt を押しながらのドラッグ = コピー**。\n", + "POST の本文には `action: \"move\"` / `\"copy\"` が入る。\n", + "\n", + "**判定**(指示 §1.7): 成功トースト(「移動に成功しました」「コピーに成功しました」)と、\n", + "`wb_responses` の POST が 2xx であること。\n", + "\n", + "ファイルは 6MB。5GB のガード(`can_intra_copy` の `file_size` 判定)は\n", + "実機では踏まない。" + ] + }, + { + "cell_type": "code", + "id": "fda929c3-f482-4694-b84a-fdb6c3d05b9e", + "metadata": { + "tags": [] + }, + "source": [ + "# 移動先・コピー先のフォルダを先に作っておく(空キーを置くと一覧でフォルダになる)\n", + "if run_intra:\n", + " _c = s3_client(s3_region_default)\n", + " for _p in (intra_copy_dest, intra_move_dest):\n", + " _c.put_object(Bucket=s3_bucket, Key=_p, Body=b'')\n", + " print('作成:', intra_copy_dest, intra_move_dest)\n", + "\n", + " s5_file_name = 'S-5-intra.bin'\n", + " s5_file_path = os.path.join(work_dir, s5_file_name)\n", + " with open(s5_file_path, 'wb') as f:\n", + " f.write(os.urandom(intra_file_size))\n", + " print(s5_file_path, os.path.getsize(s5_file_path), 'bytes')\n", + "else:\n", + " print('run_intra=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "14047924-3f47-400e-a1f6-28c39aab2bdf", + "metadata": { + "tags": [] + }, + "source": [ + "# S-3 で 1500 行を読み込んだ状態のツリーは重いので、開き直してから行う\n", + "if run_intra:\n", + " async def _step(page):\n", + " await open_project(page, s3_addon_project)\n", + " await open_files_tab(page)\n", + " await open_storage(page, addon_storage_name)\n", + " await clear_growls(page)\n", + " await grdm.upload_file(page, s5_file_path)\n", + " await wait_for_upload_finished(page, s5_file_name)\n", + " await expect(grdm.get_select_file_title_locator(page, s5_file_name)\n", + " ).to_be_visible(timeout=transition_timeout * 3)\n", + " for _name in (intra_copy_dest.rstrip('/'), intra_move_dest.rstrip('/')):\n", + " await expect(grdm.get_select_folder_title_locator(page, _name)\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + " await run_pw(_step)\n", + " dump_wb_responses('S-5-upload.json')\n", + "else:\n", + " print('run_intra=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "afbdfa1e-0925-413c-94a0-ab54c6227fba", + "metadata": { + "tags": [] + }, + "source": [ + "async def drag_and_drop_with_alt(page, source, dest):\n", + " \"\"\"Alt を押しながらドラッグする(= fangorn の copyMode を 'copy' にする)。\n", + "\n", + " fangorn.js は `$(document).keydown` で `e.altKey` を見て module 変数\n", + " `altKey` を立てる。Playwright の keyboard.down は実キーイベントを出すので\n", + " このハンドラが反応する。mouse.up のあとに離すこと(離すのが早いと\n", + " `getCopyMode` が呼ばれる時点で false に戻っている)。\n", + " \"\"\"\n", + " await page.keyboard.down('Alt')\n", + " try:\n", + " await grdm.drag_and_drop(page, source, dest)\n", + " finally:\n", + " await page.keyboard.up('Alt')\n", + "\n", + "\n", + "s5_results = {}\n", + "\n", + "if run_intra:\n", + " async def _step(page):\n", + " # --- コピー: Alt + ドラッグ\n", + " await clear_growls(page)\n", + " wb_responses.clear()\n", + " src = grdm.get_select_file_draggable_locator(page, s5_file_name)\n", + " dst = grdm.get_select_folder_droppable_locator(page, intra_copy_dest.rstrip('/'))\n", + " await drag_and_drop_with_alt(page, src, dst)\n", + " await asyncio.sleep(5)\n", + " s5_results['copy'] = {\n", + " 'growls': await growl_messages(page),\n", + " 'wb': [e for e in wb_responses if e['method'] == 'POST'],\n", + " }\n", + " # コピーなので元のファイルは残っている\n", + " await expect(grdm.get_select_file_title_locator(page, s5_file_name)\n", + " ).to_be_visible(timeout=transition_timeout)\n", + "\n", + " await run_pw(_step)\n", + " print(s5_results['copy']['growls'])\n", + "else:\n", + " print('run_intra=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "de6447db-3557-4fa2-a265-581016da695f", + "metadata": { + "tags": [] + }, + "source": [ + "if run_intra:\n", + " async def _step(page):\n", + " # --- 移動: 素のドラッグ\n", + " await clear_growls(page)\n", + " wb_responses.clear()\n", + " src = grdm.get_select_file_draggable_locator(page, s5_file_name)\n", + " dst = grdm.get_select_folder_droppable_locator(page, intra_move_dest.rstrip('/'))\n", + " await grdm.drag_and_drop(page, src, dst)\n", + " await asyncio.sleep(5)\n", + " s5_results['move'] = {\n", + " 'growls': await growl_messages(page),\n", + " 'wb': [e for e in wb_responses if e['method'] == 'POST'],\n", + " }\n", + "\n", + " await run_pw(_step)\n", + " print(s5_results['move']['growls'])\n", + "else:\n", + " print('run_intra=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "410e4169-ceb1-423f-8742-1f21f51df3fd", + "metadata": { + "tags": [] + }, + "source": [ + "# S-5 の判定: S3 側の実体と、POST の action / ステータス\n", + "if run_intra:\n", + " _c = s3_client(s3_region_default)\n", + "\n", + " def _head(key):\n", + " try:\n", + " r = _c.head_object(Bucket=s3_bucket, Key=key)\n", + " return {'exists': True, 'ContentLength': r['ContentLength']}\n", + " except Exception as e:\n", + " return {'exists': False, 'error': type(e).__name__}\n", + "\n", + " _s3_state = {\n", + " 'root': _head(s5_file_name),\n", + " 'copy_dest': _head(intra_copy_dest + s5_file_name),\n", + " 'move_dest': _head(intra_move_dest + s5_file_name),\n", + " }\n", + "\n", + " def _actions(entries):\n", + " out = []\n", + " for e in entries:\n", + " body = e.get('body') or ''\n", + " out.append({'status': e['status'], 'url': e['url'],\n", + " 'has_copy': '\"copy\"' in body or \"'copy'\" in body,\n", + " 'has_move': '\"move\"' in body or \"'move'\" in body})\n", + " return out\n", + "\n", + " _verdict = {\n", + " 's3': _s3_state,\n", + " 'copy': {'growls': s5_results.get('copy', {}).get('growls'),\n", + " 'posts': _actions(s5_results.get('copy', {}).get('wb', []))},\n", + " 'move': {'growls': s5_results.get('move', {}).get('growls'),\n", + " 'posts': _actions(s5_results.get('move', {}).get('wb', []))},\n", + " 'expected_file_size': intra_file_size,\n", + " }\n", + " record('S-5-wb-responses.json',\n", + " _redact(json.dumps(_verdict, indent=2, ensure_ascii=False)))\n", + " dump_wb_responses('S-5-wb-responses-raw.json')\n", + "\n", + " for _op, _word in (('copy', 'コピー'), ('move', '移動')):\n", + " _posts = _verdict[_op]['posts']\n", + " assert _posts, f'{_op} の POST が観測できていない(ドラッグが届いていない)'\n", + " _bad = [p for p in _posts if p['status'] >= 400]\n", + " assert not _bad, f'{_op} の POST が失敗している: {_bad}'\n", + " _growls = _verdict[_op]['growls'] or []\n", + " assert any(f'{_word}に成功しました' in g for g in _growls), \\\n", + " f'{_op} の成功トースト(「{_word}に成功しました」)が出ていない: {_growls}'\n", + "\n", + " assert _s3_state['copy_dest']['exists'], 'コピー先に実体が無い'\n", + " assert _s3_state['move_dest']['exists'], '移動先に実体が無い'\n", + " assert not _s3_state['root']['exists'], '移動したのに元の場所にファイルが残っている'\n", + " assert _s3_state['copy_dest']['ContentLength'] == intra_file_size, \\\n", + " 'コピー先のサイズが違う(CopyObject が壊れている)'\n", + " assert _s3_state['move_dest']['ContentLength'] == intra_file_size, \\\n", + " '移動先のサイズが違う'\n", + " print('S-5: copy / move ともに成功し、S3 の実体も期待どおり')\n", + "else:\n", + " print('run_intra=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "7b9bf5c1-2990-4f17-81e8-162484b17c0f", + "metadata": {}, + "source": [ + "## 後処理\n", + "\n", + "1. 証跡の INDEX を作る\n", + "2. Playwright を閉じる(ここで HAR・動画・console.log が書き出される)\n", + "3. HAR を読んで S-1(G-10)の 302 → presigned URL を判定する\n", + "4. バケットの残骸を消す\n", + "5. 後始末チェックリストを消化する(機関ストレージを戻すのは**手動**)" + ] + }, + { + "cell_type": "code", + "id": "09d57cfb-f17b-40a7-bfa3-dcd9f4bffbf9", + "metadata": { + "tags": [] + }, + "source": [ + "# 証跡の INDEX\n", + "_files = sorted(os.listdir(run_dir))\n", + "_index = ['# 証跡 INDEX',\n", + " '',\n", + " f'- run_id: {run_id}',\n", + " f'- 取得日時: {datetime.now().isoformat()}',\n", + " f'- 対象: RCOSDP/RDM-waterbutler#93 ({wb_deployed_sha}) '\n", + " f'/ RCOSDP/RDM-osf.io#746 ({osf_deployed_sha})',\n", + " '',\n", + " '| ファイル | サイズ | 対応シナリオ |',\n", + " '|---|---|---|']\n", + "_scenario = {'S-0': '環境確認 (IF-5/R-5)', 'S-1': '機関ストレージ基本操作 (IF-3既定/G-10)',\n", + " 'S-2': '明示リージョン (IF-3)', 'S-3': '1000件超一覧 (P-6/R-6)',\n", + " 'S-4': 'バージョン削除 (V-1)', 'S-5': 'intra copy/move (I-2)',\n", + " 'S-6': 'CompleteMultipartUpload Error Code (E-1)'}\n", + "for _f in _files:\n", + " _key = _f[:3]\n", + " _index.append(f'| `{_f}` | {os.path.getsize(os.path.join(run_dir, _f))} | '\n", + " f'{_scenario.get(_key, \"Playwright 成果物\")} |')\n", + "_index.append('')\n", + "_index.append('`har.zip` / `video-*.webm` / `console.log` / `last-*.{png,html}` は '\n", + " '`scripts/playwright.py` が `finish_pw_context` で書き出したもの。')\n", + "record('INDEX.md', '\\n'.join(_index))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "5660a967-a1fc-458c-9161-e1ae129d171a", + "metadata": { + "tags": [] + }, + "source": [ + "# Playwright を閉じる。**これをしないと HAR が書き出されない。**\n", + "await finish_pw_context(timeout=300)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "75db6c13-09bd-4e6f-a0a1-58a27d9e237e", + "metadata": {}, + "source": [ + "### S-1 (G-10): HAR からダウンロードのリダイレクトを判定する\n", + "\n", + "WB の `download_file` は `accept_url` が真のとき presigned URL を文字列で返し、\n", + "ハンドラが `self.redirect(stream)` する\n", + "(`waterbutler/server/api/v1/provider/metadata.py`)。\n", + "\n", + "**判定**: ダウンロード要求に対する応答が **302** で、`Location` が\n", + "`amazonaws.com` かつ `X-Amz-Signature` を含むこと(= SigV4 の presigned URL)。\n", + "\n", + "パスの `providers/` の後ろは、機関ストレージ経由なら `osfstorage`、\n", + "アドオン経由なら `s3` になる(セル [5] の表のとおり、`osfstorage` は\n", + "`download` だけは内側の `s3` に委譲するので、どちらでも 302 になる)。" + ] + }, + { + "cell_type": "code", + "id": "640a436a-400f-49ef-9edf-74382daab1a5", + "metadata": { + "tags": [] + }, + "source": [ + "import re\n", + "import zipfile\n", + "\n", + "_har_zip = os.path.join(run_dir, 'har.zip')\n", + "har_verdict = {'har_zip': _har_zip, 'exists': os.path.exists(_har_zip)}\n", + "\n", + "if har_verdict['exists']:\n", + " with zipfile.ZipFile(_har_zip) as _z:\n", + " _member = [n for n in _z.namelist() if n.endswith('.har')][0]\n", + " _har = json.loads(_z.read(_member).decode('utf-8'))\n", + "\n", + " _redirects = []\n", + " for _e in _har['log']['entries']:\n", + " _req, _res = _e['request'], _e['response']\n", + " if _req['method'] != 'GET':\n", + " continue\n", + " if '/v1/resources/' not in _req['url']:\n", + " continue\n", + " if not re.search(r'/providers/(osfstorage|s3)/', _req['url']):\n", + " continue\n", + " if 'meta=' in _req['url'] or 'zip=' in _req['url']:\n", + " continue\n", + " _loc = ''\n", + " for _h in _res.get('headers', []):\n", + " if _h['name'].lower() == 'location':\n", + " _loc = _h['value']\n", + " _redirects.append({\n", + " 'url': _req['url'],\n", + " 'status': _res['status'],\n", + " 'location_host': _loc.split('/')[2] if '://' in _loc else '',\n", + " 'location_is_amazonaws': 'amazonaws.com' in _loc,\n", + " 'location_has_signature': 'X-Amz-Signature' in _loc,\n", + " 'location_has_sigv4_algorithm': 'AWS4-HMAC-SHA256' in _loc,\n", + " 'location': re.sub(r'(X-Amz-(?:Signature|Credential|Security-Token)=)'\n", + " r'[^&\\s]+', r'\\1***', _loc),\n", + " })\n", + "\n", + " _ok = [r for r in _redirects\n", + " if r['status'] == 302 and r['location_is_amazonaws']\n", + " and r['location_has_signature']]\n", + " har_verdict['entries'] = len(_har['log']['entries'])\n", + " har_verdict['download_candidates'] = _redirects\n", + " har_verdict['presigned_redirects'] = len(_ok)\n", + " record('S-1-download-redirect.json',\n", + " _redact(json.dumps(har_verdict, indent=2, ensure_ascii=False)))\n", + " assert _ok, (\n", + " 'HAR に「302 かつ Location が X-Amz-Signature 付きの amazonaws.com」'\n", + " 'である応答が無い。決定-19(accept_url で presigned URL に飛ばす)が'\n", + " '効いていない可能性がある。S-1-download-redirect.json を見ること')\n", + " print(f'S-1 (G-10): presigned URL への 302 を {len(_ok)} 件確認')\n", + "else:\n", + " record('S-1-download-redirect.json',\n", + " json.dumps(har_verdict, indent=2, ensure_ascii=False))\n", + " raise AssertionError(\n", + " f'{_har_zip} が無い。default_result_path が run_dir を指していないか、'\n", + " 'finish_pw_context が走っていない')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "b52a9243-21e2-489d-9492-f3059f01abb1", + "metadata": { + "tags": [] + }, + "source": [ + "# バケットの残骸を消す(toomany / S-4 / S-5)\n", + "_cleanup = []\n", + "\n", + "\n", + "def _delete_prefix(bucket, prefix, region):\n", + " c = s3_client(region)\n", + " deleted = 0\n", + " paginator = c.get_paginator('list_objects_v2')\n", + " batch = []\n", + " for p in paginator.paginate(Bucket=bucket, Prefix=prefix):\n", + " for o in p.get('Contents', []):\n", + " batch.append({'Key': o['Key']})\n", + " if len(batch) == 1000:\n", + " c.delete_objects(Bucket=bucket, Delete={'Objects': batch})\n", + " deleted += len(batch)\n", + " batch = []\n", + " if batch:\n", + " c.delete_objects(Bucket=bucket, Delete={'Objects': batch})\n", + " deleted += len(batch)\n", + " return deleted\n", + "\n", + "\n", + "def _delete_all_versions(bucket, prefix, region):\n", + " \"\"\"バージョニング有効バケット用。Versions と DeleteMarkers の両方を消す。\"\"\"\n", + " c = s3_client(region)\n", + " deleted = 0\n", + " while True:\n", + " r = c.list_object_versions(Bucket=bucket, Prefix=prefix)\n", + " objs = [{'Key': v['Key'], 'VersionId': v['VersionId']}\n", + " for v in r.get('Versions', []) + r.get('DeleteMarkers', [])]\n", + " if not objs:\n", + " break\n", + " for i in range(0, len(objs), 1000):\n", + " c.delete_objects(Bucket=bucket, Delete={'Objects': objs[i:i + 1000]})\n", + " deleted += len(objs)\n", + " return deleted\n", + "\n", + "\n", + "if run_toomany:\n", + " _n = _delete_prefix(s3_bucket, toomany_prefix, s3_region_default)\n", + " _cleanup.append(f'{toomany_prefix}: {_n} 件削除')\n", + "if run_intra:\n", + " for _p in (intra_copy_dest, intra_move_dest):\n", + " _n = _delete_prefix(s3_bucket, _p, s3_region_default)\n", + " _cleanup.append(f'{_p}: {_n} 件削除')\n", + " _n = _delete_prefix(s3_bucket, s5_file_name, s3_region_default)\n", + " _cleanup.append(f'{s5_file_name}: {_n} 件削除')\n", + "if run_versioned:\n", + " _n = _delete_all_versions(s3_bucket_versioned, s4_file_name, s3_region_default)\n", + " _cleanup.append(f'{s4_file_name} (全版): {_n} 件削除')\n", + "\n", + "_left = s3_client(s3_region_default).list_objects_v2(Bucket=s3_bucket, MaxKeys=20)\n", + "_cleanup.append(f's3_bucket の残り: KeyCount={_left.get(\"KeyCount\", 0)} '\n", + " f'{[o[\"Key\"] for o in _left.get(\"Contents\", [])]}')\n", + "record('S-3-cleanup.txt', '\\n'.join(_cleanup))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "246ec03a-96e6-4e19-81b4-d6d828d4515d", + "metadata": {}, + "source": [ + "### 後始末チェックリスト\n", + "\n", + "**この証跡取りは機関ストレージの設定を 2 回上書きしている。**\n", + "`S-0-institutional-storage-before.txt` に登録前の状態が残っているので、\n", + "それを見ながら次を消化すること。\n", + "\n", + "- [ ] 機関ストレージを元の設定に戻す(**手動**。管理画面で元のプロバイダを\n", + " 選び直して保存する。「登録を外す」操作は無い)\n", + "- [ ] 本ノートブックが作ったプロジェクトを削除する\n", + " (`*-institutional` / `*-apne1` / `*-addon` / `*-versioned`)\n", + "- [ ] ユーザー設定 →「アドオンアカウント構成」から S3 アカウントの連携を解除する\n", + " (アドオンのアカウントは**ユーザー単位**で残る)\n", + "- [ ] バケットが空になっていることを確認する(上のセルの `S-3-cleanup.txt`)\n", + "- [ ] `S-0-jenkins-build.txt` を手で保存したか確認する\n", + "- [ ] 証跡ディレクトリを `aws-s3-sigv4-improvement/evidence/e2e/` 配下に置いたか確認する\n", + "- [ ] S-6(`scripts/s3_complete_error_codes.py`)を実行し、\n", + " `S-6-complete-error-codes.json` を同じ証跡ディレクトリに入れる" + ] + }, + { + "cell_type": "code", + "id": "6fce27c1-5927-4712-83e7-e44e984736f2", + "metadata": { + "tags": [] + }, + "source": [ + "# 残っているプロジェクトの一覧を出す(削除は手動で行う)\n", + "print('削除対象のプロジェクト:')\n", + "for _p in [f'{rdm_project_prefix}-institutional', f'{rdm_project_prefix}-apne1',\n", + " s3_addon_project, s4_project]:\n", + " print(' -', _p)\n", + "print()\n", + "print('証跡:', run_dir)\n", + "print('作業ディレクトリを削除します:', work_dir)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "3e8e7f26-312f-4d7c-a8de-394e39abd293", + "metadata": { + "tags": [] + }, + "source": [ + "!rm -fr {work_dir}" + ], + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 1b4ea23184e0961302accc9ed006b4734a952a1f Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Fri, 25 Sep 2026 21:13:51 +0900 Subject: [PATCH 06/19] =?UTF-8?q?test(s3):=20SigV4=E5=9B=9E=E5=B8=B0?= =?UTF-8?q?=E3=83=8E=E3=83=BC=E3=83=88=E3=83=96=E3=83=83=E3=82=AF=E3=81=AB?= =?UTF-8?q?S-7=E3=83=81=E3=83=A3=E3=83=B3=E3=82=AF=E3=82=A2=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=83=BC=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E_PLAN v0.2 の S-7(E-2 / K-4)。64MB のファイルをUIからアップロードし、 ETag の末尾にパート数の接尾辞が付くこと(= CompleteMultipartUpload を 通ったこと)とサイズ一致を確認する。run_chunked=False で無効化できる。 --- ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 267 +++++++++++++----- 1 file changed, 193 insertions(+), 74 deletions(-) diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index 1d27244..16532fc 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "id": "afcd213f-de3e-420f-8373-44232cb2821d", + "id": "9b5760a6-05f0-4359-ac70-46c1e8133383", "metadata": { "tags": [ "parameters" @@ -13,7 +13,7 @@ "from getpass import getpass\n", "\n", "# 本ノートブックは テスト手順-管理者機能-S3-機関ストレージ.ipynb の複製に\n", - "# SigV4 回帰(S-0〜S-5)のセルを足したもの。元のノートブックは変更していない。\n", + "# SigV4 回帰(S-0〜S-5、S-7)のセルを足したもの。元のノートブックは変更していない。\n", "\n", "admin_rdm_url = 'https://admin.test.rdm.example.com/'\n", "rdm_url = 'https://test.rdm.example.com/'\n", @@ -70,6 +70,8 @@ "run_toomany = True\n", "run_versioned = True\n", "run_intra = True\n", + "# S-7 チャンク(マルチパート)アップロード。E2E_PLAN v0.2 では任意\n", + "run_chunked = True\n", "# S-4 の X9 実機版(同一 Key に 1001 版)。既定は無効\n", "run_versioned_bulk = False\n", "\n", @@ -78,6 +80,8 @@ "intra_copy_dest = 'intra-copy-dest/'\n", "intra_move_dest = 'intra-move-dest/'\n", "intra_file_size = 6 * 1024 * 1024\n", + "# S-7 のファイルサイズ。マルチパートに落ちる大きさにする\n", + "chunked_file_size = 64 * 1024 * 1024\n", "\n", "# 証跡の保存先。既定は ../aws-s3-sigv4-improvement/evidence/e2e/run-\n", "evidence_dir = None\n", @@ -90,7 +94,7 @@ }, { "cell_type": "code", - "id": "47f59846-0e3b-464e-9228-ced0e67e4356", + "id": "311a3a61-329c-49ae-8df7-1edc803f1854", "metadata": { "tags": [] }, @@ -118,7 +122,7 @@ }, { "cell_type": "code", - "id": "87c6aab3-5fa5-4ebf-bd09-5d2445147b75", + "id": "e1857a2b-348b-455e-8703-e7fd09e81d6e", "metadata": { "tags": [] }, @@ -138,7 +142,7 @@ }, { "cell_type": "code", - "id": "3e4b3ae2-90d4-45aa-a1c4-8df0d90f3976", + "id": "e170c707-43ab-48a1-bcce-ca061f0b89eb", "metadata": { "tags": [] }, @@ -165,7 +169,7 @@ }, { "cell_type": "code", - "id": "3def6c3a-ba26-4d4f-a77d-e7aeaeec4388", + "id": "868af519-b3f6-42e4-9f8b-facdc6d2af72", "metadata": { "tags": [] }, @@ -221,7 +225,7 @@ }, { "cell_type": "markdown", - "id": "5fcbb5ff-9e05-44d4-a913-973157aacae4", + "id": "c6905c3e-8f7a-408e-860c-42e0b9d5ecac", "metadata": {}, "source": [ "# GakuNinRDM 総合テスト [Amazon S3 — SigV4 回帰]\n", @@ -249,6 +253,7 @@ "| S-4 バージョニング有効バケットでの削除 | **V-1**(任意で V-2) | **S3 アドオン** | `S-4-versions-before.json` / `S-4-versions-after.json` |\n", "| S-5 intra copy / move | **I-2** | **S3 アドオン** | `S-5-wb-responses.json` |\n", "| S-6 CompleteMultipartUpload の Error Code 採取 | E-1 | — | `scripts/s3_complete_error_codes.py`(本ノートブックの外) |\n", + "| S-7(任意)チャンクアップロードの正常完了 | E-2 / K-4 | **S3 アドオン** | `S-7-chunked-upload.json` |\n", "\n", "## なぜ S-3 / S-4 / S-5 は機関ストレージではなく **アドオン**で行うのか\n", "\n", @@ -322,7 +327,7 @@ }, { "cell_type": "code", - "id": "3f349198-2a56-4698-a2bb-d9ae334b1947", + "id": "554174e8-8da9-41e0-bac6-035f3c73bac0", "metadata": { "tags": [] }, @@ -346,7 +351,7 @@ }, { "cell_type": "markdown", - "id": "3f887e6e-918c-4698-871f-18093b0fd12f", + "id": "c9666573-e6fc-49ff-8378-c30c46d777f9", "metadata": {}, "source": [ "### WaterButler への応答を記録する仕掛けを入れる\n", @@ -358,7 +363,7 @@ }, { "cell_type": "code", - "id": "a7e47570-ebec-4c38-9fe7-691e6422bc4c", + "id": "91ef7f4e-c6e5-42fc-9b9d-357683c40bf8", "metadata": { "tags": [] }, @@ -501,7 +506,7 @@ }, { "cell_type": "markdown", - "id": "0c3c1d46-ace3-40d6-9ee8-08205fd8ba9d", + "id": "cf11a6f6-c0e0-4741-a2e9-3854f6db4694", "metadata": {}, "source": [ "## S-0: 環境確認(IF-5 / R-5)\n", @@ -531,7 +536,7 @@ }, { "cell_type": "code", - "id": "43e43af5-9177-41f8-839f-aa7f412fc4a7", + "id": "ba8a9930-be61-43c2-9380-ec3de20d13f0", "metadata": { "tags": [] }, @@ -582,7 +587,7 @@ }, { "cell_type": "code", - "id": "2a6d7892-8fee-4585-985f-d23887ea25bd", + "id": "cea53d1e-4ecc-4b7c-80ed-d7cd582ba6d5", "metadata": { "tags": [] }, @@ -631,7 +636,7 @@ }, { "cell_type": "markdown", - "id": "b1f23dc3-621e-4777-8128-b3d85acfdedc", + "id": "4aef6f56-0591-482a-a525-4d9de5c895a6", "metadata": {}, "source": [ "## S-1: 機関ストレージ登録 + 基本操作(IF-3 既定 / G-10)\n", @@ -645,7 +650,7 @@ }, { "cell_type": "code", - "id": "09e4d977-5729-42d1-94e1-a007f458a448", + "id": "6dd828a2-f7bc-491b-9576-db519cd3fd51", "metadata": { "tags": [] }, @@ -662,7 +667,7 @@ }, { "cell_type": "markdown", - "id": "5d6741e9-e627-401d-abc0-4ae338241995", + "id": "4d6898c4-c449-490b-b956-4c65053c7256", "metadata": {}, "source": [ "### ログイン情報を用いてGakuNin RDM管理者画面にログインする\n", @@ -676,7 +681,7 @@ }, { "cell_type": "code", - "id": "f15d0739-31f4-4720-8392-c875e3065652", + "id": "8417bdac-8ea2-4139-bcc1-1cc4bb8ff4f6", "metadata": { "tags": [] }, @@ -768,7 +773,7 @@ }, { "cell_type": "markdown", - "id": "2bdbe936-c7d1-46c5-b60f-4599fda96cd0", + "id": "0ae7939a-6d25-4743-a4ce-acd499bedd15", "metadata": {}, "source": [ "### 機関ストレージの登録手順を関数にする\n", @@ -783,7 +788,7 @@ }, { "cell_type": "code", - "id": "0b5090b1-57bf-436a-83e5-bd56aebc3d5f", + "id": "0cfe6869-6675-4b1e-95f6-0ff4ab283746", "metadata": { "tags": [] }, @@ -831,7 +836,7 @@ }, { "cell_type": "code", - "id": "83cbfc8b-4cb3-40ec-ae18-ff26c828f2d5", + "id": "0d7f780e-85ab-4bdc-93bf-ed42396e26c8", "metadata": { "tags": [] }, @@ -931,7 +936,7 @@ }, { "cell_type": "code", - "id": "26de3e91-b12a-4910-b220-0f709d596204", + "id": "d5d61119-a7ab-4841-bb4a-114014aa84d7", "metadata": { "tags": [] }, @@ -951,7 +956,7 @@ }, { "cell_type": "code", - "id": "0b9796bd-d124-4fbe-8791-fd535c751bb7", + "id": "fd60e331-6299-45a4-90c2-5736b851a504", "metadata": { "tags": [] }, @@ -966,7 +971,7 @@ }, { "cell_type": "markdown", - "id": "f9c7d513-ae2f-4550-a4e6-c2ba770711ac", + "id": "2cc9e8e2-360e-41ff-8ac7-5a280c39b067", "metadata": {}, "source": [ "### 管理者画面からログアウトする\n", @@ -976,7 +981,7 @@ }, { "cell_type": "code", - "id": "1a87a923-797a-4314-9dfe-01e68c5855b5", + "id": "d8ee5c66-7b3b-487b-8085-f25ab5b03075", "metadata": { "tags": [] }, @@ -998,7 +1003,7 @@ }, { "cell_type": "markdown", - "id": "82741ed3-8713-49b6-8ad2-948d3f3cd2e5", + "id": "e8d1d633-8da9-4ee0-a140-c87a40688424", "metadata": {}, "source": [ "### Part 2: ユーザ画面でのプロジェクト作成・アップロード・ダウンロード\n", @@ -1011,7 +1016,7 @@ }, { "cell_type": "code", - "id": "ed1cfa85-5f84-4e97-a0a6-83ca701a4b42", + "id": "9c479f86-4468-4027-80cd-a88eb0f79cb5", "metadata": { "tags": [] }, @@ -1039,7 +1044,7 @@ }, { "cell_type": "code", - "id": "08bb087c-03b0-4e46-86d0-ed57501da49a", + "id": "5793dbb7-a3a6-425e-b970-3bafd089491c", "metadata": { "tags": [] }, @@ -1058,7 +1063,7 @@ }, { "cell_type": "code", - "id": "a355c5f4-653b-4c75-94a0-c0bde166754d", + "id": "239bc0ca-320c-4c95-afd2-2c77b05130e9", "metadata": { "tags": [] }, @@ -1100,7 +1105,7 @@ }, { "cell_type": "code", - "id": "b8990a5f-0a92-4ab8-8a2f-684620021525", + "id": "9e5a0d4b-7cb8-474f-98a8-2a8ba577a6f1", "metadata": { "tags": [] }, @@ -1120,7 +1125,7 @@ }, { "cell_type": "code", - "id": "a1797373-e3ad-4e0f-9637-30dfe9b92258", + "id": "ebda357f-c5e2-464c-b395-995683f77d9f", "metadata": { "tags": [] }, @@ -1139,7 +1144,7 @@ }, { "cell_type": "code", - "id": "16a5f58c-f4f8-4a3f-bc49-1cb9ef10f7ea", + "id": "d6e32f74-ad74-4719-88e3-51ac54a31114", "metadata": { "tags": [] }, @@ -1159,7 +1164,7 @@ }, { "cell_type": "markdown", - "id": "08c07b56-8995-43e5-afb1-de4a05187ae7", + "id": "2999590e-237f-4352-a6bc-862b28899642", "metadata": {}, "source": [ "### S-1 (G-10): ダウンロードする\n", @@ -1175,7 +1180,7 @@ }, { "cell_type": "code", - "id": "0bb9a41d-2d50-40ac-885f-083d8299f16b", + "id": "3d50c4fe-7740-42d3-b2cc-03294fc4c899", "metadata": { "tags": [] }, @@ -1210,7 +1215,7 @@ }, { "cell_type": "code", - "id": "ea73d766-fed9-4bee-b524-48db3860e60b", + "id": "5230f617-d3a2-4ab5-b4d0-52665554fd5c", "metadata": { "tags": [] }, @@ -1229,7 +1234,7 @@ }, { "cell_type": "markdown", - "id": "995ee7ff-09ac-483d-a3df-b19b9d61a53f", + "id": "f9c0f041-ab2c-4ab1-b7c9-6422e282d9fe", "metadata": {}, "source": [ "## S-2: 明示リージョン(`ap-northeast-1`)(IF-3)\n", @@ -1250,7 +1255,7 @@ }, { "cell_type": "code", - "id": "eef6d10f-a1bd-4740-b80a-48575f4feea9", + "id": "ee39ca16-426c-4bbf-918d-c318b4f38132", "metadata": { "tags": [] }, @@ -1273,7 +1278,7 @@ }, { "cell_type": "code", - "id": "4328ad4d-0059-4ba0-97b9-1d36fe49f2ee", + "id": "8ee8b2d4-e5ab-4626-b49f-7fe870f26148", "metadata": { "tags": [] }, @@ -1304,7 +1309,7 @@ }, { "cell_type": "code", - "id": "e21c044c-724a-404a-83f3-883b4dad62ed", + "id": "378a6b65-fc67-42a4-958d-56409af22989", "metadata": { "tags": [] }, @@ -1343,7 +1348,7 @@ }, { "cell_type": "code", - "id": "0253fceb-e70f-4a5b-87dc-ccda05915ff4", + "id": "12eae4d4-cec1-461b-9ef1-ba1a6e4d231c", "metadata": { "tags": [] }, @@ -1375,7 +1380,7 @@ }, { "cell_type": "markdown", - "id": "10d72381-d159-46a4-a31b-d48ec6cce261", + "id": "10ef9e64-c86a-4636-a376-e34913c808ed", "metadata": {}, "source": [ "## S-3 〜 S-5 の前に: プロジェクトに **S3 アドオン**を接続する\n", @@ -1390,7 +1395,7 @@ }, { "cell_type": "code", - "id": "7e1d1f33-26f9-4e3e-bda7-fec7d9cdd4f2", + "id": "b3478da4-08cf-4f6e-9c71-b0d5713d0486", "metadata": { "tags": [] }, @@ -1472,7 +1477,7 @@ }, { "cell_type": "code", - "id": "08424ce4-af34-46fa-897f-afbe27b8c15c", + "id": "e714d94c-1ddd-47ba-ad8a-6e6de73cb336", "metadata": { "tags": [] }, @@ -1521,7 +1526,7 @@ }, { "cell_type": "markdown", - "id": "31e707a6-9c26-4d12-ba47-0bd3a8bc14f2", + "id": "6174e83e-5b6a-4797-b2d1-a8e1e9aee5d1", "metadata": {}, "source": [ "## S-3: 1000 件超フォルダの一覧(P-6 / R-6)\n", @@ -1543,7 +1548,7 @@ }, { "cell_type": "code", - "id": "b399f054-00bd-409c-a436-7defc37e103d", + "id": "49eddb78-c8c8-4b62-9945-c83cf3053974", "metadata": { "tags": [] }, @@ -1591,7 +1596,7 @@ }, { "cell_type": "code", - "id": "2d4cc295-1767-4808-bc88-9f7d286220ab", + "id": "e104e7dc-8aef-4e24-b3bd-3884e2df6c19", "metadata": { "tags": [] }, @@ -1612,7 +1617,7 @@ }, { "cell_type": "markdown", - "id": "49c47126-c214-418d-84de-893f83330fd1", + "id": "8432a820-87f1-4d72-918d-6e3e16292fa3", "metadata": {}, "source": [ "### `toomany` フォルダを開き、底までスクロールして追加読み込みさせる\n", @@ -1623,7 +1628,7 @@ }, { "cell_type": "code", - "id": "5e9d1e31-4687-460c-99a7-df13c67ffd5c", + "id": "39d87eda-3fbd-4b17-b4ab-0b9e169f658d", "metadata": { "tags": [] }, @@ -1669,7 +1674,7 @@ }, { "cell_type": "code", - "id": "7516a48e-27dd-4ff9-a3e2-46f9cd93afd5", + "id": "549b8a52-5440-4377-9efe-c3006106a01a", "metadata": { "tags": [] }, @@ -1725,7 +1730,7 @@ }, { "cell_type": "markdown", - "id": "b1c61ef9-baea-426b-8ecb-e7c1014d7962", + "id": "f7130865-135d-4341-aadc-ae324280d92e", "metadata": {}, "source": [ "### (任意) 全 1500 行を 1 件ずつ画面でたどる\n", @@ -1739,7 +1744,7 @@ }, { "cell_type": "code", - "id": "e08b315a-5c35-4ef2-a942-5cbfa16b0fbf", + "id": "a4bd3ec3-596d-486a-9900-64c4a1ced501", "metadata": { "tags": [] }, @@ -1775,7 +1780,7 @@ }, { "cell_type": "markdown", - "id": "de49bb7d-cdcb-4848-8e83-ce6869d3834f", + "id": "659b9105-4ba1-4e72-ab30-13c9856a584c", "metadata": {}, "source": [ "## S-4: バージョニング有効バケットでの削除(V-1)\n", @@ -1791,7 +1796,7 @@ }, { "cell_type": "code", - "id": "47de8ac1-f1e9-40fe-aef3-c1364b5506da", + "id": "155e349d-ae5c-47ae-b6b8-ee1d7931d99d", "metadata": { "tags": [] }, @@ -1837,7 +1842,7 @@ }, { "cell_type": "code", - "id": "29cd858b-9a2d-4865-bd1e-cda717948399", + "id": "8f921fa4-7fa4-4f8f-b843-4746f2ff3c99", "metadata": { "tags": [] }, @@ -1874,7 +1879,7 @@ }, { "cell_type": "code", - "id": "50476bda-369f-4896-8cd0-b11a0fba5acf", + "id": "26d805da-e1b6-48f7-881d-48e0c2f4b0ef", "metadata": { "tags": [] }, @@ -1896,7 +1901,7 @@ }, { "cell_type": "code", - "id": "da037810-4846-411f-8c98-647e613ccc0e", + "id": "8621ba6f-a416-496c-8908-4a88334c144c", "metadata": { "tags": [] }, @@ -1924,7 +1929,7 @@ }, { "cell_type": "code", - "id": "11b8417d-e3c1-40db-9404-b4c4ef502ef5", + "id": "2f5093ca-09e2-481b-8801-c2a2c3118c72", "metadata": { "tags": [] }, @@ -1960,7 +1965,7 @@ }, { "cell_type": "code", - "id": "e0ad04d1-a78d-4a86-97f0-bf5ae7ba3457", + "id": "ef44f2d5-27b2-4c3c-81e2-183869568e37", "metadata": { "tags": [] }, @@ -1992,7 +1997,7 @@ }, { "cell_type": "markdown", - "id": "dbe917c0-2280-4856-884b-88a642acffcc", + "id": "3ef80f52-c3b7-4c7e-a216-0dcec3ae0b48", "metadata": {}, "source": [ "## S-5: intra copy / move(I-2)\n", @@ -2021,7 +2026,7 @@ }, { "cell_type": "code", - "id": "fda929c3-f482-4694-b84a-fdb6c3d05b9e", + "id": "58bd71d9-fee0-4fde-b159-7bd1ecbf03d5", "metadata": { "tags": [] }, @@ -2046,7 +2051,7 @@ }, { "cell_type": "code", - "id": "14047924-3f47-400e-a1f6-28c39aab2bdf", + "id": "89b1b73e-404c-468b-9cea-c7aa183af97c", "metadata": { "tags": [] }, @@ -2076,7 +2081,7 @@ }, { "cell_type": "code", - "id": "afbdfa1e-0925-413c-94a0-ab54c6227fba", + "id": "c16b9c47-2a81-4787-b4ef-d9d0ef8e8ef1", "metadata": { "tags": [] }, @@ -2125,7 +2130,7 @@ }, { "cell_type": "code", - "id": "de6447db-3557-4fa2-a265-581016da695f", + "id": "b6872cb7-5143-435d-865e-6b1b5ae378a6", "metadata": { "tags": [] }, @@ -2154,7 +2159,7 @@ }, { "cell_type": "code", - "id": "410e4169-ceb1-423f-8742-1f21f51df3fd", + "id": "3e727114-032c-4f2a-9ef0-5695aa8cbb36", "metadata": { "tags": [] }, @@ -2222,7 +2227,117 @@ }, { "cell_type": "markdown", - "id": "7b9bf5c1-2990-4f17-81e8-162484b17c0f", + "id": "927fd81d-9ec3-4d06-a5e3-6ea2ec7b9d43", + "metadata": {}, + "source": [ + "## (任意) S-7: チャンク(マルチパート)アップロード(E-2 / K-4)\n", + "\n", + "E2E_PLAN v0.2 の S-7。実 AWS に数十 MB のファイルを上げて**正常完了**することだけを見る。\n", + "`CompleteMultipartUpload` の**失敗系**は UI からは起こせないので S-6(boto3 スクリプト)と\n", + "単体テストで担保する — ここは「成功経路が SigV4 で通る」ことの証跡。\n", + "\n", + "マルチパートで上がったかどうかは **ETag の末尾**でわかる。単発 PUT の ETag は\n", + "MD5(32 桁 hex)だが、マルチパートの ETag は `-<パート数>` になる。\n", + "したがって `ETag` に `-` があれば `CompleteMultipartUpload` を通っている。\n", + "\n", + "既定 64MB。`run_chunked=False` で飛ばせる。アップロードに数分かかることがある。" + ] + }, + { + "cell_type": "code", + "id": "dd6d2c31-b85c-40f0-bc32-34ccd47dcb2a", + "metadata": { + "tags": [] + }, + "source": [ + "# S-7: 数十 MB のファイルを作る\n", + "if run_chunked:\n", + " s7_file_name = 'S-7-chunked.bin'\n", + " s7_file_path = os.path.join(work_dir, s7_file_name)\n", + " with open(s7_file_path, 'wb') as f:\n", + " # os.urandom を一度に 64MB 呼ぶとメモリを食うので分割して書く\n", + " _remain = chunked_file_size\n", + " while _remain > 0:\n", + " _n = min(_remain, 4 * 1024 * 1024)\n", + " f.write(os.urandom(_n))\n", + " _remain -= _n\n", + " print(s7_file_path, os.path.getsize(s7_file_path), 'bytes')\n", + "else:\n", + " print('run_chunked=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "df293996-8ad8-4190-a17a-a8caf0779065", + "metadata": { + "tags": [] + }, + "source": [ + "if run_chunked:\n", + " async def _step(page):\n", + " await open_project(page, s3_addon_project)\n", + " await open_files_tab(page)\n", + " await open_storage(page, addon_storage_name)\n", + " await clear_growls(page)\n", + " wb_responses.clear()\n", + " await grdm.upload_file(page, s7_file_path)\n", + " # 64MB なので既定より長めに待つ\n", + " await wait_for_upload_finished(page, s7_file_name,\n", + " timeout=transition_timeout * 30)\n", + " await expect(grdm.get_select_file_title_locator(page, s7_file_name)\n", + " ).to_be_visible(timeout=transition_timeout * 3)\n", + "\n", + " await run_pw(_step)\n", + " dump_wb_responses('S-7-upload.json')\n", + "else:\n", + " print('run_chunked=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "81fc47e2-b6d7-48d0-bef9-7ee57aa89549", + "metadata": { + "tags": [] + }, + "source": [ + "# S-7 の判定: S3 の実体サイズと ETag、WB 応答に 4xx/5xx が無いこと\n", + "if run_chunked:\n", + " _c = s3_client(s3_region_default)\n", + " _h = _c.head_object(Bucket=s3_bucket, Key=s7_file_name)\n", + " _etag = _h['ETag'].strip('\"')\n", + " _bad = [e for e in wb_responses if e['status'] >= 400]\n", + " _verdict = {\n", + " 'key': s7_file_name,\n", + " 'expected_size': chunked_file_size,\n", + " 'ContentLength': _h['ContentLength'],\n", + " 'ETag': _etag,\n", + " 'multipart': '-' in _etag,\n", + " 'part_count': int(_etag.split('-')[1]) if '-' in _etag else None,\n", + " 'wb_4xx_5xx': [{'status': e['status'], 'url': e['url']} for e in _bad],\n", + " }\n", + " record('S-7-chunked-upload.json',\n", + " _redact(json.dumps(_verdict, indent=2, ensure_ascii=False)))\n", + " assert not _bad, f'WB 応答に 4xx/5xx がある: {_verdict[\"wb_4xx_5xx\"]}'\n", + " assert _h['ContentLength'] == chunked_file_size, \\\n", + " f'サイズが違う: {_h[\"ContentLength\"]} != {chunked_file_size}'\n", + " if not _verdict['multipart']:\n", + " print('注意: ETag にパート数の接尾辞が無い。'\n", + " 'この size では WB が単発 PUT を選んだ可能性がある'\n", + " '(E-2 の経路は通っていない)。chunked_file_size を上げて再実行する。')\n", + " print('S-7:', _verdict['ContentLength'], 'bytes, ETag =', _etag)\n", + "else:\n", + " print('run_chunked=False のためスキップ')" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "887e9338-1861-4a99-b6c0-03e053672e24", "metadata": {}, "source": [ "## 後処理\n", @@ -2236,7 +2351,7 @@ }, { "cell_type": "code", - "id": "09d57cfb-f17b-40a7-bfa3-dcd9f4bffbf9", + "id": "7e9e16c2-5dca-42b3-88d6-79dc88fc547f", "metadata": { "tags": [] }, @@ -2255,7 +2370,8 @@ "_scenario = {'S-0': '環境確認 (IF-5/R-5)', 'S-1': '機関ストレージ基本操作 (IF-3既定/G-10)',\n", " 'S-2': '明示リージョン (IF-3)', 'S-3': '1000件超一覧 (P-6/R-6)',\n", " 'S-4': 'バージョン削除 (V-1)', 'S-5': 'intra copy/move (I-2)',\n", - " 'S-6': 'CompleteMultipartUpload Error Code (E-1)'}\n", + " 'S-6': 'CompleteMultipartUpload Error Code (E-1)',\n", + " 'S-7': 'チャンクアップロード (E-2/K-4)'}\n", "for _f in _files:\n", " _key = _f[:3]\n", " _index.append(f'| `{_f}` | {os.path.getsize(os.path.join(run_dir, _f))} | '\n", @@ -2270,7 +2386,7 @@ }, { "cell_type": "code", - "id": "5660a967-a1fc-458c-9161-e1ae129d171a", + "id": "b35573a2-9943-4102-bf7e-f1410b3c1649", "metadata": { "tags": [] }, @@ -2283,7 +2399,7 @@ }, { "cell_type": "markdown", - "id": "75db6c13-09bd-4e6f-a0a1-58a27d9e237e", + "id": "3522eabc-f9de-4b0a-96a1-29e3d94ffb6d", "metadata": {}, "source": [ "### S-1 (G-10): HAR からダウンロードのリダイレクトを判定する\n", @@ -2302,7 +2418,7 @@ }, { "cell_type": "code", - "id": "640a436a-400f-49ef-9edf-74382daab1a5", + "id": "42a494ed-b1da-4e36-8d8d-f3d21e876e4d", "metadata": { "tags": [] }, @@ -2369,12 +2485,12 @@ }, { "cell_type": "code", - "id": "b52a9243-21e2-489d-9492-f3059f01abb1", + "id": "9659679b-12c9-4d3c-b908-eeaad34ead9f", "metadata": { "tags": [] }, "source": [ - "# バケットの残骸を消す(toomany / S-4 / S-5)\n", + "# バケットの残骸を消す(toomany / S-4 / S-5 / S-7)\n", "_cleanup = []\n", "\n", "\n", @@ -2421,6 +2537,9 @@ " _cleanup.append(f'{_p}: {_n} 件削除')\n", " _n = _delete_prefix(s3_bucket, s5_file_name, s3_region_default)\n", " _cleanup.append(f'{s5_file_name}: {_n} 件削除')\n", + "if run_chunked:\n", + " _n = _delete_prefix(s3_bucket, s7_file_name, s3_region_default)\n", + " _cleanup.append(f'{s7_file_name}: {_n} 件削除')\n", "if run_versioned:\n", " _n = _delete_all_versions(s3_bucket_versioned, s4_file_name, s3_region_default)\n", " _cleanup.append(f'{s4_file_name} (全版): {_n} 件削除')\n", @@ -2435,7 +2554,7 @@ }, { "cell_type": "markdown", - "id": "246ec03a-96e6-4e19-81b4-d6d828d4515d", + "id": "0d1510a2-e768-4493-b8cb-3c9b784d0a52", "metadata": {}, "source": [ "### 後始末チェックリスト\n", @@ -2459,7 +2578,7 @@ }, { "cell_type": "code", - "id": "6fce27c1-5927-4712-83e7-e44e984736f2", + "id": "c0339d17-ca28-4c23-ab2a-d8113ab0cde1", "metadata": { "tags": [] }, @@ -2478,7 +2597,7 @@ }, { "cell_type": "code", - "id": "3e8e7f26-312f-4d7c-a8de-394e39abd293", + "id": "e99f935e-c095-43e6-bbe1-f26d3b25efc3", "metadata": { "tags": [] }, From fd41870b2800fef92a9d84ca6386acc21e0a3bb5 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Sat, 26 Sep 2026 07:42:26 +0900 Subject: [PATCH 07/19] =?UTF-8?q?test(s3):=20S-7=E3=82=92200MB=E3=81=AB?= =?UTF-8?q?=E5=A4=89=E6=9B=B4=E3=81=97S-6=E3=81=AB=E8=AA=8D=E8=A8=BC?= =?UTF-8?q?=E7=B3=BB3=E3=82=B1=E3=83=BC=E3=82=B9=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1: WB の CONTIGUOUS_UPLOAD_SIZE_LIMIT は既定 128,000,000 で、64MB では 必ず単発 PUT になり CompleteMultipartUpload を通らない。chunked_file_size を 200,000,000(CHUNK_SIZE 64,000,000 × 3 + 8,000,000 = 4 パート)に変更し、 閾値の根拠を S-7 の説明セルに記載する。判定はマルチパート経路を assert し、 パート数の一致は env で閾値を変えられるため警告どまりとする。 P2-2: invalid-access-key-id / signature-does-not-match / no-such-bucket の 3 ケースを追加。いずれも UploadId とパートは正しい資格情報で作り、complete の 呼び出しだけを壊す。EntityTooLarge は 5TiB 超が必要なため採取不可として 理由付きで表に残す。 --- scripts/s3_complete_error_codes.py | 136 +++++++++- ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 252 +++++++++++------- 2 files changed, 288 insertions(+), 100 deletions(-) diff --git a/scripts/s3_complete_error_codes.py b/scripts/s3_complete_error_codes.py index 6ce3601..c434f30 100644 --- a/scripts/s3_complete_error_codes.py +++ b/scripts/s3_complete_error_codes.py @@ -54,9 +54,23 @@ # S3 の最小パートサイズ。非最終パートがこれ未満だと EntityTooSmall になる。 MIN_PART_SIZE = 5 * 1024 * 1024 +# 架空のアクセスキー。実在しない形式(AKIA + 16 文字)にしてある。 +# 出力には伏せ字ではなくこのまま出す(実在しないので伏せる必要がない)が、 +# redact() の正規表現に引っかかるため、印字時は _BOGUS_DISPLAY を使う。 +BOGUS_ACCESS_KEY = 'AKIA' + 'INVALIDINVALIDIN' +_BOGUS_DISPLAY = '<架空のアクセスキー (AKIA + INVALID...)>' + OUT_JSON = 'S-6-complete-error-codes.json' OUT_MD = 'S-6-complete-error-codes.md' +# 本スクリプトの構成では実機採取できない 9 コードと、その理由。 +UNCOLLECTABLE_REASONS = { + 'EntityTooLarge': ( + '1 オブジェクト 5TiB 超(または 1 パート 5GiB 超)でしか返らない。' + '転送量・費用・所要時間の点で E2E では採取しない。' + 'ガードは単体テストで担保する。'), +} + def classify(code): """WaterButler が返す分類。表にあれば NOT_COMMITTED、無ければ既定の UNKNOWN。""" @@ -111,6 +125,23 @@ def classify(code): 'complete_multipart_upload(Parts=[1]) # 2 回目 ← これを記録する', 'delete_object(Key=) # 後始末(1 回目で実体ができている)', ]), + ('invalid-access-key-id', '架空のアクセスキーのクライアントで complete する', [ + 'create_multipart_upload(Key=) # 正しい資格情報で', + 'upload_part(PartNumber=1, Body=<5MiB>)', + f'complete_multipart_upload(...) # aws_access_key_id={_BOGUS_DISPLAY}', + 'abort_multipart_upload() # 後始末は正しい資格情報で', + ]), + ('signature-does-not-match', '正しいアクセスキー + 壊したシークレットで complete する', [ + 'create_multipart_upload(Key=) # 正しい資格情報で', + 'upload_part(PartNumber=1, Body=<5MiB>)', + 'complete_multipart_upload(...) # secret の末尾 1 文字を別の文字に置換', + 'abort_multipart_upload() # 後始末は正しい資格情報で', + ]), + ('no-such-bucket', '存在しないバケットに対して complete する', [ + 'complete_multipart_upload(Bucket=-does-not-exist-, ' + 'UploadId=<架空>, Parts=[1])', + '# 後始末なし(何も作っていない)', + ]), ] CASE_NOTES = { @@ -122,6 +153,17 @@ def classify(code): 'access-denied': ( '事前にユーザーが「s3:PutObject を拒否するバケットポリシー」を付けた' 'バケットを用意し、環境変数 E2E_S3_DENY_BUCKET で渡すこと。'), + 'invalid-access-key-id': ( + 'UploadId とパートは正しい資格情報で作る。壊すのは complete の呼び出し' + 'だけなので、「complete がこのコードで落ちたときオブジェクトは無い」と' + 'いう表の主張をそのまま検証できる。'), + 'signature-does-not-match': ( + 'シークレットの末尾 1 文字を別の文字に置換する。シークレットそのものは' + '出力にも例外文にも出さない(botocore は署名計算にしか使わない)。'), + 'no-such-bucket': ( + 'NoSuchBucket と NoSuchUpload のどちらが先に返るかを記録する。' + 'バケットが無ければ UploadId の存在は問えないので NoSuchBucket が' + '期待値だが、実機の順序は未確認。'), } @@ -175,14 +217,41 @@ def print_plan(args, bucket, region, deny_bucket): # --------------------------------------------------------------------------- -def _client(region): +def _client(region, access_key=None, secret_key=None): + """S3 クライアント。資格情報を渡さなければ通常の解決順(環境変数など)。 + + access_key / secret_key を明示すると、その資格情報だけを使う + (ケース 8・9 用。壊した資格情報で complete を投げるため)。 + """ import boto3 from botocore.config import Config - return boto3.client( - 's3', - region_name=region, - config=Config(signature_version='s3v4', retries={'max_attempts': 3}), - ) + kwargs = { + 'region_name': region, + 'config': Config(signature_version='s3v4', retries={'max_attempts': 1}), + } + if access_key is not None: + kwargs['aws_access_key_id'] = access_key + kwargs['aws_secret_access_key'] = secret_key + kwargs['aws_session_token'] = None + return boto3.client('s3', **kwargs) + + +def _break_secret(secret): + """シークレットの末尾 1 文字を別の文字に置き換える。 + + 元のシークレットは返り値に残らない…わけではない(末尾以外は同じ)ので、 + この値は**絶対に出力しない**。署名計算にだけ渡す。 + """ + last = secret[-1] + return secret[:-1] + ('B' if last != 'B' else 'C') + + +def _nonexistent_bucket(bucket): + """存在しないバケット名を作る。S3 の命名規則(63 文字以内・小文字)に収める。""" + import uuid + suffix = '-does-not-exist-' + uuid.uuid4().hex[:8] + base = bucket.lower()[:63 - len(suffix)] + return base + suffix def _observe(fn): @@ -242,7 +311,8 @@ def _cleanup(client, bucket, key, upload_id, obs): return out -def run_cases(client, bucket, deny_bucket, prefix, with_access_denied): +def run_cases(client, bucket, deny_bucket, prefix, with_access_denied, + region='us-east-1', access_key=None, secret_key=None): body_5m = b'0' * MIN_PART_SIZE body_1m = b'0' * (1024 * 1024) results = [] @@ -361,6 +431,46 @@ def key_for(name): extra['cleanup'] = type(e).__name__ results.append(('completed-twice', second, extra)) + # ---------------- 8. invalid-access-key-id + # UploadId とパートは正しい資格情報で作り、complete だけ架空のキーで投げる。 + key = key_for('invalid-access-key-id') + up = client.create_multipart_upload(Bucket=bucket, Key=key)['UploadId'] + etag = client.upload_part(Bucket=bucket, Key=key, UploadId=up, + PartNumber=1, Body=body_5m)['ETag'] + bad_key_client = _client(region, BOGUS_ACCESS_KEY, 'bogus-secret-not-a-real-one') + obs = _observe(lambda: bad_key_client.complete_multipart_upload( + Bucket=bucket, Key=key, UploadId=up, + MultipartUpload={'Parts': [{'PartNumber': 1, 'ETag': etag}]})) + results.append(('invalid-access-key-id', obs, + _cleanup(client, bucket, key, up, obs))) + + # ---------------- 9. signature-does-not-match + if secret_key: + key = key_for('signature-does-not-match') + up = client.create_multipart_upload(Bucket=bucket, Key=key)['UploadId'] + etag = client.upload_part(Bucket=bucket, Key=key, UploadId=up, + PartNumber=1, Body=body_5m)['ETag'] + bad_sig_client = _client(region, access_key, _break_secret(secret_key)) + obs = _observe(lambda: bad_sig_client.complete_multipart_upload( + Bucket=bucket, Key=key, UploadId=up, + MultipartUpload={'Parts': [{'PartNumber': 1, 'ETag': etag}]})) + results.append(('signature-does-not-match', obs, + _cleanup(client, bucket, key, up, obs))) + + # ---------------- 10. no-such-bucket + # 何も作らない。存在しないバケットに架空の UploadId で complete を投げる。 + ghost = _nonexistent_bucket(bucket) + obs = _observe(lambda: client.complete_multipart_upload( + Bucket=ghost, Key=key_for('no-such-bucket'), + UploadId='ZmFrZS11cGxvYWQtaWQtZm9yLXMtNg', + MultipartUpload={'Parts': [ + {'PartNumber': 1, 'ETag': '"d41d8cd98f00b204e9800998ecf8427e"'}]})) + results.append(('no-such-bucket', obs, { + 'bucket': ghost, + 'note': 'NoSuchBucket と NoSuchUpload のどちらが先に返るかを見る。' + '後始末は不要(何も作っていない)。', + })) + return results @@ -393,6 +503,7 @@ def build_report(results, bucket, region, started_at, skipped): 'skipped_cases': skipped, 'observed_codes': sorted(observed), 'uncollected_table_codes': uncollected, + 'uncollectable_by_design': UNCOLLECTABLE_REASONS, 'codes_not_in_table': unlisted, 'note': ('コード側(DEFINITIVE_REJECTION_CODES)は変更していない。' '表に無いコードは WaterButler の既定どおり UNKNOWN として扱う' @@ -439,9 +550,12 @@ def build_markdown(report): lines.append('| `{}` | 採取(**表に無い**) | UNKNOWN(既定) |'.format(code)) if report['uncollected_table_codes']: - lines += ['', '未採取のコード(本スクリプトの 7 ケースでは出せないもの):', ''] + lines += ['', '未採取のコードと理由:', ''] for code in report['uncollected_table_codes']: - lines.append(f'- `{code}`') + reason = UNCOLLECTABLE_REASONS.get( + code, '本スクリプトのケースでは出せなかった(' + '該当ケースがスキップされたか、実装差で別のコードが返った)。') + lines.append(f'- `{code}` — {reason}') if report['skipped_cases']: lines += ['', '## スキップしたケース', ''] @@ -508,7 +622,9 @@ def main(argv=None): started_at = datetime.now().isoformat() client = _client(region) results = run_cases(client, bucket, deny_bucket, args.prefix, - args.with_access_denied) + args.with_access_denied, + region=region, access_key=access_key, + secret_key=secret_key) report = build_report(results, bucket, region, started_at, skipped) text_json = redact(json.dumps(report, indent=2, ensure_ascii=False)) diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index 16532fc..f45d2fa 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "id": "9b5760a6-05f0-4359-ac70-46c1e8133383", + "id": "6cd0704e-db54-484e-9159-482911cfb644", "metadata": { "tags": [ "parameters" @@ -80,8 +80,14 @@ "intra_copy_dest = 'intra-copy-dest/'\n", "intra_move_dest = 'intra-move-dest/'\n", "intra_file_size = 6 * 1024 * 1024\n", - "# S-7 のファイルサイズ。マルチパートに落ちる大きさにする\n", - "chunked_file_size = 64 * 1024 * 1024\n", + "# S-7 のファイルサイズ。WB の CONTIGUOUS_UPLOAD_SIZE_LIMIT(既定 128,000,000)を\n", + "# 超えないとマルチパートにならない。既定 200,000,000 = 64,000,000 × 3 + 8,000,000\n", + "# の 4 パート。セル [62] の説明を参照。\n", + "chunked_file_size = 200000000\n", + "# WB providers/s3/settings.py の既定値。staging2 で env により変えられていたら\n", + "# ここも合わせる(判定は警告どまりにしてある)。\n", + "wb_chunk_size = 64000000\n", + "wb_contiguous_upload_size_limit = 128000000\n", "\n", "# 証跡の保存先。既定は ../aws-s3-sigv4-improvement/evidence/e2e/run-\n", "evidence_dir = None\n", @@ -94,7 +100,7 @@ }, { "cell_type": "code", - "id": "311a3a61-329c-49ae-8df7-1edc803f1854", + "id": "1bfe9a1f-0e67-45a2-8a49-7b58a4054ef9", "metadata": { "tags": [] }, @@ -122,7 +128,7 @@ }, { "cell_type": "code", - "id": "e1857a2b-348b-455e-8703-e7fd09e81d6e", + "id": "2b794e3a-392d-43b8-ba0d-ff272a412b2f", "metadata": { "tags": [] }, @@ -142,7 +148,7 @@ }, { "cell_type": "code", - "id": "e170c707-43ab-48a1-bcce-ca061f0b89eb", + "id": "439782c2-041c-4842-8b28-330b5a6519f0", "metadata": { "tags": [] }, @@ -169,7 +175,7 @@ }, { "cell_type": "code", - "id": "868af519-b3f6-42e4-9f8b-facdc6d2af72", + "id": "b5bc73d0-b8cc-4678-a34b-9dd0e13942a8", "metadata": { "tags": [] }, @@ -225,7 +231,7 @@ }, { "cell_type": "markdown", - "id": "c6905c3e-8f7a-408e-860c-42e0b9d5ecac", + "id": "f467d712-fa2f-4946-8093-5c94e89834df", "metadata": {}, "source": [ "# GakuNinRDM 総合テスト [Amazon S3 — SigV4 回帰]\n", @@ -253,7 +259,7 @@ "| S-4 バージョニング有効バケットでの削除 | **V-1**(任意で V-2) | **S3 アドオン** | `S-4-versions-before.json` / `S-4-versions-after.json` |\n", "| S-5 intra copy / move | **I-2** | **S3 アドオン** | `S-5-wb-responses.json` |\n", "| S-6 CompleteMultipartUpload の Error Code 採取 | E-1 | — | `scripts/s3_complete_error_codes.py`(本ノートブックの外) |\n", - "| S-7(任意)チャンクアップロードの正常完了 | E-2 / K-4 | **S3 アドオン** | `S-7-chunked-upload.json` |\n", + "| S-7(任意)チャンクアップロードの正常完了(200MB / 4 パート) | E-2 / K-4 | **S3 アドオン** | `S-7-chunked-upload.json` |\n", "\n", "## なぜ S-3 / S-4 / S-5 は機関ストレージではなく **アドオン**で行うのか\n", "\n", @@ -327,7 +333,7 @@ }, { "cell_type": "code", - "id": "554174e8-8da9-41e0-bac6-035f3c73bac0", + "id": "08959fba-527e-4083-8913-b776df7a40b0", "metadata": { "tags": [] }, @@ -351,7 +357,7 @@ }, { "cell_type": "markdown", - "id": "c9666573-e6fc-49ff-8378-c30c46d777f9", + "id": "74dfb6e8-6194-4d07-9c21-77c66ee2bcea", "metadata": {}, "source": [ "### WaterButler への応答を記録する仕掛けを入れる\n", @@ -363,7 +369,7 @@ }, { "cell_type": "code", - "id": "91ef7f4e-c6e5-42fc-9b9d-357683c40bf8", + "id": "b87ce4a5-3339-45fa-af51-f1fa168134d4", "metadata": { "tags": [] }, @@ -506,7 +512,7 @@ }, { "cell_type": "markdown", - "id": "cf11a6f6-c0e0-4741-a2e9-3854f6db4694", + "id": "412d7218-1a61-4bc7-b6c0-da8351fc971c", "metadata": {}, "source": [ "## S-0: 環境確認(IF-5 / R-5)\n", @@ -536,7 +542,7 @@ }, { "cell_type": "code", - "id": "ba8a9930-be61-43c2-9380-ec3de20d13f0", + "id": "4075c85e-c8b1-4871-ae60-d39ad29b677c", "metadata": { "tags": [] }, @@ -587,7 +593,7 @@ }, { "cell_type": "code", - "id": "cea53d1e-4ecc-4b7c-80ed-d7cd582ba6d5", + "id": "c439e246-6dda-4862-a551-a88235913f4a", "metadata": { "tags": [] }, @@ -636,7 +642,7 @@ }, { "cell_type": "markdown", - "id": "4aef6f56-0591-482a-a525-4d9de5c895a6", + "id": "1cb8783b-8e0c-4497-8967-f856f66f1fe2", "metadata": {}, "source": [ "## S-1: 機関ストレージ登録 + 基本操作(IF-3 既定 / G-10)\n", @@ -650,7 +656,7 @@ }, { "cell_type": "code", - "id": "6dd828a2-f7bc-491b-9576-db519cd3fd51", + "id": "9aa5bdf5-2843-4d1a-84cd-628b37b7db0d", "metadata": { "tags": [] }, @@ -667,7 +673,7 @@ }, { "cell_type": "markdown", - "id": "4d6898c4-c449-490b-b956-4c65053c7256", + "id": "d673d1e5-ebae-49d3-ae1b-e21a010c1546", "metadata": {}, "source": [ "### ログイン情報を用いてGakuNin RDM管理者画面にログインする\n", @@ -681,7 +687,7 @@ }, { "cell_type": "code", - "id": "8417bdac-8ea2-4139-bcc1-1cc4bb8ff4f6", + "id": "0ada7061-85df-4850-831e-6797434bc694", "metadata": { "tags": [] }, @@ -773,7 +779,7 @@ }, { "cell_type": "markdown", - "id": "0ae7939a-6d25-4743-a4ce-acd499bedd15", + "id": "8c4d5c5c-b0be-417d-872d-33406240185b", "metadata": {}, "source": [ "### 機関ストレージの登録手順を関数にする\n", @@ -788,7 +794,7 @@ }, { "cell_type": "code", - "id": "0cfe6869-6675-4b1e-95f6-0ff4ab283746", + "id": "8273b542-accd-4bef-929e-0fdd182b2e6e", "metadata": { "tags": [] }, @@ -836,7 +842,7 @@ }, { "cell_type": "code", - "id": "0d7f780e-85ab-4bdc-93bf-ed42396e26c8", + "id": "17de9668-e609-4bb3-a863-810e4b03f36b", "metadata": { "tags": [] }, @@ -936,7 +942,7 @@ }, { "cell_type": "code", - "id": "d5d61119-a7ab-4841-bb4a-114014aa84d7", + "id": "dee82caf-c0b4-4d2d-88ff-64fcfba23a42", "metadata": { "tags": [] }, @@ -956,7 +962,7 @@ }, { "cell_type": "code", - "id": "fd60e331-6299-45a4-90c2-5736b851a504", + "id": "a5036057-f2d6-47d9-9cd3-b109a968939d", "metadata": { "tags": [] }, @@ -971,7 +977,7 @@ }, { "cell_type": "markdown", - "id": "2cc9e8e2-360e-41ff-8ac7-5a280c39b067", + "id": "ec24e4c5-d472-4b41-9988-e8a24bc5c66f", "metadata": {}, "source": [ "### 管理者画面からログアウトする\n", @@ -981,7 +987,7 @@ }, { "cell_type": "code", - "id": "d8ee5c66-7b3b-487b-8085-f25ab5b03075", + "id": "d75156a9-f58d-4a24-8577-20c279056193", "metadata": { "tags": [] }, @@ -1003,7 +1009,7 @@ }, { "cell_type": "markdown", - "id": "e8d1d633-8da9-4ee0-a140-c87a40688424", + "id": "0b4fd9ea-09c9-438e-a973-42dec9fc8741", "metadata": {}, "source": [ "### Part 2: ユーザ画面でのプロジェクト作成・アップロード・ダウンロード\n", @@ -1016,7 +1022,7 @@ }, { "cell_type": "code", - "id": "9c479f86-4468-4027-80cd-a88eb0f79cb5", + "id": "3b4f9fb4-7e3a-4e9c-8580-b4db79280e28", "metadata": { "tags": [] }, @@ -1044,7 +1050,7 @@ }, { "cell_type": "code", - "id": "5793dbb7-a3a6-425e-b970-3bafd089491c", + "id": "4cdca549-4509-4c07-8d4d-75a37041752c", "metadata": { "tags": [] }, @@ -1063,7 +1069,7 @@ }, { "cell_type": "code", - "id": "239bc0ca-320c-4c95-afd2-2c77b05130e9", + "id": "3416dfc6-5e41-4e5a-9374-69dc9dc2bb47", "metadata": { "tags": [] }, @@ -1105,7 +1111,7 @@ }, { "cell_type": "code", - "id": "9e5a0d4b-7cb8-474f-98a8-2a8ba577a6f1", + "id": "0c39ae2c-f750-4ae0-8591-cd0fd67e163a", "metadata": { "tags": [] }, @@ -1125,7 +1131,7 @@ }, { "cell_type": "code", - "id": "ebda357f-c5e2-464c-b395-995683f77d9f", + "id": "ec46621b-10cf-4072-8a73-02771c91ad23", "metadata": { "tags": [] }, @@ -1144,7 +1150,7 @@ }, { "cell_type": "code", - "id": "d6e32f74-ad74-4719-88e3-51ac54a31114", + "id": "51d31f2d-1c9a-485e-b848-8bb3504ec4ab", "metadata": { "tags": [] }, @@ -1164,7 +1170,7 @@ }, { "cell_type": "markdown", - "id": "2999590e-237f-4352-a6bc-862b28899642", + "id": "182b6029-e9e4-453c-a1e0-56c715d00fdd", "metadata": {}, "source": [ "### S-1 (G-10): ダウンロードする\n", @@ -1180,7 +1186,7 @@ }, { "cell_type": "code", - "id": "3d50c4fe-7740-42d3-b2cc-03294fc4c899", + "id": "6852b64a-fa9b-4cdd-9a6f-02362dedfe93", "metadata": { "tags": [] }, @@ -1215,7 +1221,7 @@ }, { "cell_type": "code", - "id": "5230f617-d3a2-4ab5-b4d0-52665554fd5c", + "id": "ef0fd790-e7e3-45fc-a537-de53d2144d33", "metadata": { "tags": [] }, @@ -1234,7 +1240,7 @@ }, { "cell_type": "markdown", - "id": "f9c0f041-ab2c-4ab1-b7c9-6422e282d9fe", + "id": "7ac9a7bc-83b1-4ed1-a939-c8c7d44df116", "metadata": {}, "source": [ "## S-2: 明示リージョン(`ap-northeast-1`)(IF-3)\n", @@ -1255,7 +1261,7 @@ }, { "cell_type": "code", - "id": "ee39ca16-426c-4bbf-918d-c318b4f38132", + "id": "74b71abd-96bb-40f1-8b97-55caffbb4038", "metadata": { "tags": [] }, @@ -1278,7 +1284,7 @@ }, { "cell_type": "code", - "id": "8ee8b2d4-e5ab-4626-b49f-7fe870f26148", + "id": "15b2a10e-dd46-4d70-a190-7938985576e6", "metadata": { "tags": [] }, @@ -1309,7 +1315,7 @@ }, { "cell_type": "code", - "id": "378a6b65-fc67-42a4-958d-56409af22989", + "id": "6f7dbc52-3ebd-49cb-bd56-fa169334ea7e", "metadata": { "tags": [] }, @@ -1348,7 +1354,7 @@ }, { "cell_type": "code", - "id": "12eae4d4-cec1-461b-9ef1-ba1a6e4d231c", + "id": "043ef108-1d0a-445d-a8f3-f58eb2581554", "metadata": { "tags": [] }, @@ -1380,7 +1386,7 @@ }, { "cell_type": "markdown", - "id": "10ef9e64-c86a-4636-a376-e34913c808ed", + "id": "a896454c-ce8d-4d95-a51c-6c47d43b79d9", "metadata": {}, "source": [ "## S-3 〜 S-5 の前に: プロジェクトに **S3 アドオン**を接続する\n", @@ -1395,7 +1401,7 @@ }, { "cell_type": "code", - "id": "b3478da4-08cf-4f6e-9c71-b0d5713d0486", + "id": "77fa9d63-73f5-4f13-a3a9-a922ea38de0d", "metadata": { "tags": [] }, @@ -1477,7 +1483,7 @@ }, { "cell_type": "code", - "id": "e714d94c-1ddd-47ba-ad8a-6e6de73cb336", + "id": "5459eaec-89a8-40fc-985f-4eb15ea28aaa", "metadata": { "tags": [] }, @@ -1526,7 +1532,7 @@ }, { "cell_type": "markdown", - "id": "6174e83e-5b6a-4797-b2d1-a8e1e9aee5d1", + "id": "513df9f2-06ff-4ebc-bb3a-be6c5e2422f2", "metadata": {}, "source": [ "## S-3: 1000 件超フォルダの一覧(P-6 / R-6)\n", @@ -1548,7 +1554,7 @@ }, { "cell_type": "code", - "id": "49eddb78-c8c8-4b62-9945-c83cf3053974", + "id": "1e6d5c26-98b0-4dda-8b4e-51e189dda72b", "metadata": { "tags": [] }, @@ -1596,7 +1602,7 @@ }, { "cell_type": "code", - "id": "e104e7dc-8aef-4e24-b3bd-3884e2df6c19", + "id": "51888efc-d303-4c8e-b08a-e7263365276d", "metadata": { "tags": [] }, @@ -1617,7 +1623,7 @@ }, { "cell_type": "markdown", - "id": "8432a820-87f1-4d72-918d-6e3e16292fa3", + "id": "4f684521-adc4-4dcf-bfa2-d127c587882c", "metadata": {}, "source": [ "### `toomany` フォルダを開き、底までスクロールして追加読み込みさせる\n", @@ -1628,7 +1634,7 @@ }, { "cell_type": "code", - "id": "39d87eda-3fbd-4b17-b4ab-0b9e169f658d", + "id": "41526ff3-9057-49b0-b2d9-03d7cfcdfa4e", "metadata": { "tags": [] }, @@ -1674,7 +1680,7 @@ }, { "cell_type": "code", - "id": "549b8a52-5440-4377-9efe-c3006106a01a", + "id": "2b3f6ca6-b014-412f-af9a-e6e8404c5db8", "metadata": { "tags": [] }, @@ -1730,7 +1736,7 @@ }, { "cell_type": "markdown", - "id": "f7130865-135d-4341-aadc-ae324280d92e", + "id": "954ac215-c8ab-4a9a-86ec-c5599dff3de6", "metadata": {}, "source": [ "### (任意) 全 1500 行を 1 件ずつ画面でたどる\n", @@ -1744,7 +1750,7 @@ }, { "cell_type": "code", - "id": "a4bd3ec3-596d-486a-9900-64c4a1ced501", + "id": "ffa553b2-4fec-401f-8588-44402c1feee2", "metadata": { "tags": [] }, @@ -1780,7 +1786,7 @@ }, { "cell_type": "markdown", - "id": "659b9105-4ba1-4e72-ab30-13c9856a584c", + "id": "91a02d87-45d1-43a8-bf2d-b9908378b07e", "metadata": {}, "source": [ "## S-4: バージョニング有効バケットでの削除(V-1)\n", @@ -1796,7 +1802,7 @@ }, { "cell_type": "code", - "id": "155e349d-ae5c-47ae-b6b8-ee1d7931d99d", + "id": "5527a665-1a67-449a-bc8e-fd961004e248", "metadata": { "tags": [] }, @@ -1842,7 +1848,7 @@ }, { "cell_type": "code", - "id": "8f921fa4-7fa4-4f8f-b843-4746f2ff3c99", + "id": "145697c2-9d0d-42e6-9a95-ff55f1ef5f8a", "metadata": { "tags": [] }, @@ -1879,7 +1885,7 @@ }, { "cell_type": "code", - "id": "26d805da-e1b6-48f7-881d-48e0c2f4b0ef", + "id": "e8796c1d-c71f-4293-b06c-470f9a90cf55", "metadata": { "tags": [] }, @@ -1901,7 +1907,7 @@ }, { "cell_type": "code", - "id": "8621ba6f-a416-496c-8908-4a88334c144c", + "id": "e9b7d902-097d-4e27-a63a-30a31eb91d0c", "metadata": { "tags": [] }, @@ -1929,7 +1935,7 @@ }, { "cell_type": "code", - "id": "2f5093ca-09e2-481b-8801-c2a2c3118c72", + "id": "792d6b73-dabe-4d1e-995b-23e9b04464e8", "metadata": { "tags": [] }, @@ -1965,7 +1971,7 @@ }, { "cell_type": "code", - "id": "ef44f2d5-27b2-4c3c-81e2-183869568e37", + "id": "0cb0e212-d57e-4fea-8125-68f8fd6a5028", "metadata": { "tags": [] }, @@ -1997,7 +2003,7 @@ }, { "cell_type": "markdown", - "id": "3ef80f52-c3b7-4c7e-a216-0dcec3ae0b48", + "id": "5914d08b-4079-4866-9fd9-a4eb0b067513", "metadata": {}, "source": [ "## S-5: intra copy / move(I-2)\n", @@ -2026,7 +2032,7 @@ }, { "cell_type": "code", - "id": "58bd71d9-fee0-4fde-b159-7bd1ecbf03d5", + "id": "641abea6-4ca6-4a02-b791-131b0fafd209", "metadata": { "tags": [] }, @@ -2051,7 +2057,7 @@ }, { "cell_type": "code", - "id": "89b1b73e-404c-468b-9cea-c7aa183af97c", + "id": "c2afbe56-ba4b-4e12-b73f-a9f48ef52338", "metadata": { "tags": [] }, @@ -2081,7 +2087,7 @@ }, { "cell_type": "code", - "id": "c16b9c47-2a81-4787-b4ef-d9d0ef8e8ef1", + "id": "39bbfd4e-4e9d-4981-8efe-60deec57584d", "metadata": { "tags": [] }, @@ -2130,7 +2136,7 @@ }, { "cell_type": "code", - "id": "b6872cb7-5143-435d-865e-6b1b5ae378a6", + "id": "3d3233d0-0114-4fd1-b34b-21700e867848", "metadata": { "tags": [] }, @@ -2159,7 +2165,7 @@ }, { "cell_type": "code", - "id": "3e727114-032c-4f2a-9ef0-5695aa8cbb36", + "id": "14aec4cd-6665-4c3b-af1f-afc4a11d70a6", "metadata": { "tags": [] }, @@ -2227,41 +2233,91 @@ }, { "cell_type": "markdown", - "id": "927fd81d-9ec3-4d06-a5e3-6ea2ec7b9d43", + "id": "9b423e05-853d-4e36-b707-df564b739d7e", "metadata": {}, "source": [ "## (任意) S-7: チャンク(マルチパート)アップロード(E-2 / K-4)\n", "\n", - "E2E_PLAN v0.2 の S-7。実 AWS に数十 MB のファイルを上げて**正常完了**することだけを見る。\n", + "E2E_PLAN v0.2 の S-7。実 AWS に大きめのファイルを上げて**正常完了**することだけを見る。\n", "`CompleteMultipartUpload` の**失敗系**は UI からは起こせないので S-6(boto3 スクリプト)と\n", "単体テストで担保する — ここは「成功経路が SigV4 で通る」ことの証跡。\n", "\n", + "### ファイルサイズの決め方(ここを間違えると E-2 の経路を通らない)\n", + "\n", + "WB は**サイズを見て単発 PUT とマルチパートを選ぶ**。`providers/s3/provider.py` L872:\n", + "\n", + "```python\n", + "if stream.size < self.CONTIGUOUS_UPLOAD_SIZE_LIMIT:\n", + " await self._contiguous_upload(stream, path)\n", + "else:\n", + " await self._chunked_upload(stream, path)\n", + "```\n", + "\n", + "閾値は `providers/s3/settings.py` の既定値:\n", + "\n", + "| 設定 | 既定値 | 意味 |\n", + "|---|---|---|\n", + "| `CONTIGUOUS_UPLOAD_SIZE_LIMIT` | `128000000`(128MB) | これ**未満**なら単発 PUT |\n", + "| `CHUNK_SIZE` | `64000000`(64MB) | マルチパート 1 パートの大きさ |\n", + "\n", + "したがって **64MB では必ず単発 PUT になり、`CompleteMultipartUpload` を通らない。**\n", + "既定を **200,000,000 バイト**にしてある。パート数は `_upload_parts`(L996-1002)が\n", + "\n", + "```python\n", + "parts = [self.CHUNK_SIZE for i in range(0, stream.size // self.CHUNK_SIZE)]\n", + "if stream.size % self.CHUNK_SIZE:\n", + " parts.append(stream.size - (len(parts) * self.CHUNK_SIZE))\n", + "```\n", + "\n", + "と切るので `ceil(size / CHUNK_SIZE)`。200,000,000 なら **64MB × 3 + 8MB = 4 パート**。\n", + "\n", + "### 判定\n", + "\n", "マルチパートで上がったかどうかは **ETag の末尾**でわかる。単発 PUT の ETag は\n", "MD5(32 桁 hex)だが、マルチパートの ETag は `-<パート数>` になる。\n", - "したがって `ETag` に `-` があれば `CompleteMultipartUpload` を通っている。\n", "\n", - "既定 64MB。`run_chunked=False` で飛ばせる。アップロードに数分かかることがある。" + "1. ETag に `-N` が付いていること(= `CompleteMultipartUpload` を通った)— **assert**\n", + "2. `N == ceil(chunked_file_size / CHUNK_SIZE)`(既定では **4**)— **警告どまり**\n", + "\n", + "2 を警告にとどめるのは、`CONTIGUOUS_UPLOAD_SIZE_LIMIT` も `CHUNK_SIZE` も\n", + "`S3_PROVIDER_CONFIG` の環境変数で上書きできるため。staging2 側で変えられていれば\n", + "パート数は変わる。その場合は `wb_chunk_size` / `wb_contiguous_upload_size_limit`\n", + "(セル [0])を実際の値に合わせる。\n", + "\n", + "`run_chunked=False` で飛ばせる。200MB なのでアップロードに数分かかる。" ] }, { "cell_type": "code", - "id": "dd6d2c31-b85c-40f0-bc32-34ccd47dcb2a", + "id": "b07b1df5-bfc6-440a-821c-3b14b363305e", "metadata": { "tags": [] }, "source": [ - "# S-7: 数十 MB のファイルを作る\n", + "# S-7: 200MB のファイルを作り、期待パート数を計算する\n", "if run_chunked:\n", + " assert chunked_file_size >= wb_contiguous_upload_size_limit, (\n", + " f'chunked_file_size={chunked_file_size} は '\n", + " f'CONTIGUOUS_UPLOAD_SIZE_LIMIT={wb_contiguous_upload_size_limit} 未満なので、'\n", + " 'WB は単発 PUT を選ぶ。CompleteMultipartUpload を通らない')\n", + "\n", + " # _upload_parts(provider.py L996-1002)と同じ切り方\n", + " s7_expected_parts = chunked_file_size // wb_chunk_size\n", + " if chunked_file_size % wb_chunk_size:\n", + " s7_expected_parts += 1\n", + "\n", " s7_file_name = 'S-7-chunked.bin'\n", " s7_file_path = os.path.join(work_dir, s7_file_name)\n", " with open(s7_file_path, 'wb') as f:\n", - " # os.urandom を一度に 64MB 呼ぶとメモリを食うので分割して書く\n", + " # os.urandom を一度に 200MB 呼ぶとメモリを食うので分割して書く\n", " _remain = chunked_file_size\n", " while _remain > 0:\n", " _n = min(_remain, 4 * 1024 * 1024)\n", " f.write(os.urandom(_n))\n", " _remain -= _n\n", " print(s7_file_path, os.path.getsize(s7_file_path), 'bytes')\n", + " print(f'期待パート数: {s7_expected_parts} '\n", + " f'(= ceil({chunked_file_size} / {wb_chunk_size}))')\n", "else:\n", " print('run_chunked=False のためスキップ')" ], @@ -2270,7 +2326,7 @@ }, { "cell_type": "code", - "id": "df293996-8ad8-4190-a17a-a8caf0779065", + "id": "5d342d56-2431-474a-b300-36fe2c6ab987", "metadata": { "tags": [] }, @@ -2283,9 +2339,9 @@ " await clear_growls(page)\n", " wb_responses.clear()\n", " await grdm.upload_file(page, s7_file_path)\n", - " # 64MB なので既定より長めに待つ\n", + " # 200MB なので既定より長めに待つ(回線次第で数分かかる)\n", " await wait_for_upload_finished(page, s7_file_name,\n", - " timeout=transition_timeout * 30)\n", + " timeout=transition_timeout * 60)\n", " await expect(grdm.get_select_file_title_locator(page, s7_file_name)\n", " ).to_be_visible(timeout=transition_timeout * 3)\n", "\n", @@ -2299,7 +2355,7 @@ }, { "cell_type": "code", - "id": "81fc47e2-b6d7-48d0-bef9-7ee57aa89549", + "id": "42089af6-76d7-4137-9d9e-574cb4369e22", "metadata": { "tags": [] }, @@ -2317,6 +2373,11 @@ " 'ETag': _etag,\n", " 'multipart': '-' in _etag,\n", " 'part_count': int(_etag.split('-')[1]) if '-' in _etag else None,\n", + " 'expected_part_count': s7_expected_parts,\n", + " 'wb_settings_assumed': {\n", + " 'CONTIGUOUS_UPLOAD_SIZE_LIMIT': wb_contiguous_upload_size_limit,\n", + " 'CHUNK_SIZE': wb_chunk_size,\n", + " },\n", " 'wb_4xx_5xx': [{'status': e['status'], 'url': e['url']} for e in _bad],\n", " }\n", " record('S-7-chunked-upload.json',\n", @@ -2324,11 +2385,22 @@ " assert not _bad, f'WB 応答に 4xx/5xx がある: {_verdict[\"wb_4xx_5xx\"]}'\n", " assert _h['ContentLength'] == chunked_file_size, \\\n", " f'サイズが違う: {_h[\"ContentLength\"]} != {chunked_file_size}'\n", - " if not _verdict['multipart']:\n", - " print('注意: ETag にパート数の接尾辞が無い。'\n", - " 'この size では WB が単発 PUT を選んだ可能性がある'\n", - " '(E-2 の経路は通っていない)。chunked_file_size を上げて再実行する。')\n", - " print('S-7:', _verdict['ContentLength'], 'bytes, ETag =', _etag)\n", + "\n", + " # 1) マルチパートを通ったこと自体は必須(E-2 の経路)\n", + " assert _verdict['multipart'], (\n", + " f'ETag にパート数の接尾辞が無い({_etag})。単発 PUT で上がっており、'\n", + " 'CompleteMultipartUpload を通っていない。staging2 の '\n", + " 'S3_PROVIDER_CONFIG.CONTIGUOUS_UPLOAD_SIZE_LIMIT を確認し、'\n", + " 'chunked_file_size をそれ以上にして再実行する')\n", + "\n", + " # 2) パート数の一致は警告どまり(閾値は env で変えられるため)\n", + " if _verdict['part_count'] != s7_expected_parts:\n", + " print(f'警告: パート数が {_verdict[\"part_count\"]}、期待は '\n", + " f'{s7_expected_parts}(CHUNK_SIZE={wb_chunk_size} 前提)。'\n", + " 'staging2 の S3_PROVIDER_CONFIG.CHUNK_SIZE が既定と違う可能性がある。'\n", + " 'マルチパート経路自体は通っているので E-2 としては合格。')\n", + " print('S-7:', _verdict['ContentLength'], 'bytes, ETag =', _etag,\n", + " f'(パート数 {_verdict[\"part_count\"]} / 期待 {s7_expected_parts})')\n", "else:\n", " print('run_chunked=False のためスキップ')" ], @@ -2337,7 +2409,7 @@ }, { "cell_type": "markdown", - "id": "887e9338-1861-4a99-b6c0-03e053672e24", + "id": "28707a96-ab28-4569-8af8-02e478da8360", "metadata": {}, "source": [ "## 後処理\n", @@ -2351,7 +2423,7 @@ }, { "cell_type": "code", - "id": "7e9e16c2-5dca-42b3-88d6-79dc88fc547f", + "id": "5e77a19c-7a40-43c9-9769-248f9f7e353b", "metadata": { "tags": [] }, @@ -2386,7 +2458,7 @@ }, { "cell_type": "code", - "id": "b35573a2-9943-4102-bf7e-f1410b3c1649", + "id": "2cae73fd-2503-42e3-8f54-3173b60f9249", "metadata": { "tags": [] }, @@ -2399,7 +2471,7 @@ }, { "cell_type": "markdown", - "id": "3522eabc-f9de-4b0a-96a1-29e3d94ffb6d", + "id": "a9746476-3c4f-46d2-aee1-10346bdebe03", "metadata": {}, "source": [ "### S-1 (G-10): HAR からダウンロードのリダイレクトを判定する\n", @@ -2418,7 +2490,7 @@ }, { "cell_type": "code", - "id": "42a494ed-b1da-4e36-8d8d-f3d21e876e4d", + "id": "fc0fae33-eb1d-4ec2-b492-fd47f868ef67", "metadata": { "tags": [] }, @@ -2485,7 +2557,7 @@ }, { "cell_type": "code", - "id": "9659679b-12c9-4d3c-b908-eeaad34ead9f", + "id": "912198d5-7817-4c61-95f9-f894c727619f", "metadata": { "tags": [] }, @@ -2554,7 +2626,7 @@ }, { "cell_type": "markdown", - "id": "0d1510a2-e768-4493-b8cb-3c9b784d0a52", + "id": "e9df4f52-b0aa-42e5-b1a0-914991624f57", "metadata": {}, "source": [ "### 後始末チェックリスト\n", @@ -2578,7 +2650,7 @@ }, { "cell_type": "code", - "id": "c0339d17-ca28-4c23-ab2a-d8113ab0cde1", + "id": "d063bc4b-4b14-4ba6-aaa5-f595a0f2cc0f", "metadata": { "tags": [] }, @@ -2597,7 +2669,7 @@ }, { "cell_type": "code", - "id": "e99f935e-c095-43e6-bbe1-f26d3b25efc3", + "id": "1c1abd55-df20-4fff-82e3-1edb897d6580", "metadata": { "tags": [] }, From 7e08e3cd901665cd9a75ceb083a8c7265c1ab818 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Mon, 28 Sep 2026 07:56:21 +0900 Subject: [PATCH 08/19] test(s3): let .config.yaml supply the RDM URLs for the SigV4 notebook --- ...203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index f45d2fa..0cd1bab 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -15,8 +15,10 @@ "# 本ノートブックは テスト手順-管理者機能-S3-機関ストレージ.ipynb の複製に\n", "# SigV4 回帰(S-0〜S-5、S-7)のセルを足したもの。元のノートブックは変更していない。\n", "\n", - "admin_rdm_url = 'https://admin.test.rdm.example.com/'\n", - "rdm_url = 'https://test.rdm.example.com/'\n", + "# URL は .config.yaml(admin_rdm_url / rdm_url)か papermill で与える。セル [1] の補完は\n", + "# None のパラメータにしか効かないので、ここに既定値を書くと .config.yaml が無視される。\n", + "admin_rdm_url = None\n", + "rdm_url = None\n", "idp_name_1 = 'GakuNin RDM IdP'\n", "\n", "\n", From 139eac6b348ebb4144e96629663b806d02f6a3ba Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Mon, 28 Sep 2026 08:47:01 +0900 Subject: [PATCH 09/19] test(s3): create and delete the SigV4 test buckets inside the notebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SigV4 回帰ノートブックのバケットを、クォータ NB と同じ「生成から削除までを 1 ライフサイクルにし、atexit で後始末を登録する」方式にした(boto3、mc は使わない)。 - s3_bucket / s3_bucket_versioned / s3_bucket_apne1 は「指定があればそれを使い (削除しない)、None なら生成する(後始末で delete_bucket まで行う)」に変更。 既定は 3 つとも None。bucket_prefix / bucket_suffix / delete_generated_buckets を追加 - 新セル [6]: 3 バケットを生成(us-east-1 は CreateBucketConfiguration を付けない、 apne1 は LocationConstraint を付ける)、ver はバージョニングを有効化して確認、 GetBucketLocation を記録(us-east-1 が null になることが IF-3 の根拠)、 atexit に cleanup_buckets を登録。S-6 用の export 行を印字する - 旧 S-0 バケット確認セルは削除し、証跡 S-0-buckets.json を生成セルに統合 - 後始末: 生成バケットは版・削除マーカー・進行中 multipart を消してから delete_bucket(BucketNotEmpty は最大 3 回やり直し)、指定バケットは残骸だけ削除。 記録先を S-3-cleanup.txt から S-9-cleanup.txt に変更し、生成バケットが消えた ことを確認するセルをチェックリストの直後に追加 - README と scripts/s3_complete_error_codes.py の E2E_S3_BUCKET の説明を更新 moto[s3] で生成 → バージョニング Enabled → 後始末(delete_bucket まで)と、 バケット指定時に削除されないことを確認した。staging2 / AWS には実行していない。 --- README.md | 4 +- scripts/s3_complete_error_codes.py | 7 + ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 517 ++++++++++++++---- 3 files changed, 431 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index e7f8548..6bdce4e 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,9 @@ GRDMには、一般的なGUI操作を行うためのユーティリティ関数 ## Amazon S3 SigV4 回帰試験 `テスト手順-管理者機能-S3-機関ストレージ-SigV4回帰.ipynb` は、WaterButler の Amazon S3 プロバイダを SigV4 化した際の回帰試験 (S-0〜S-7) を実施するテスト手順 Notebook です。既存の `テスト手順-管理者機能-S3-機関ストレージ.ipynb` とは別ファイルとして管理し、機関ストレージ登録・大量ファイル一覧・バージョニング削除・サーバ側コピー/移動を確認します。 -`scripts/s3_complete_error_codes.py` は、CompleteMultipartUpload が AWS S3 から返すエラーコードを採取して WaterButler の判定表と突き合わせる単体スクリプトです (Notebook の S-6 に対応)。`--dry-run` で実行計画のみを表示できます。認証情報は環境変数から読み込み、出力には含めません。 +試験に使う S3 バケット 3 つ (通常 / バージョニング有効 / ap-northeast-1) は、**既定では Notebook が実行時に生成し、後始末で削除します**。名前は `{bucket_prefix}-{bucket_suffix}-{std,ver,apne1}` (既定 `e2e-sigv4--<4桁乱数>-…`) で、生成セルが `S-0-buckets.json` に記録します。既存のバケットを使う場合は `.config.yaml` に `s3_bucket` / `s3_bucket_versioned` / `s3_bucket_apne1` の 3 つを書いてください。指定したバケットは本試験が置いた残骸だけを消し、バケット自体は削除しません。`s3:CreateBucket` / `s3:PutBucketVersioning` / `s3:DeleteBucket` を含む IAM 権限が必要です (不足時は生成セルが必要な権限一覧を表示して停止します)。 + +`scripts/s3_complete_error_codes.py` は、CompleteMultipartUpload が AWS S3 から返すエラーコードを採取して WaterButler の判定表と突き合わせる単体スクリプトです (Notebook の S-6 に対応)。`--dry-run` で実行計画のみを表示できます。認証情報は環境変数から読み込み、出力には含めません。対象バケットは環境変数 `E2E_S3_BUCKET` で指定しますが、Notebook がバケットを生成する運用では生成セルが出力する `export E2E_S3_BUCKET=…` / `export E2E_S3_REGION=…` の行をそのまま貼って実行します (Notebook の後始末より前に実行すること)。 ## セキュリティと機密情報の管理 diff --git a/scripts/s3_complete_error_codes.py b/scripts/s3_complete_error_codes.py index c434f30..64a2855 100644 --- a/scripts/s3_complete_error_codes.py +++ b/scripts/s3_complete_error_codes.py @@ -23,6 +23,13 @@ export E2E_S3_REGION=us-east-1 # 省略時 us-east-1 python3 scripts/s3_complete_error_codes.py --out <証跡ディレクトリ> +``E2E_S3_BUCKET`` / ``E2E_S3_REGION`` の 2 行は、SigV4 回帰ノートブックの +バケット生成セル(セル [6])が ``export …`` の形で印字するので、それをそのまま +貼ればよい。ノートブックは既定でバケットを生成し**後始末で削除する**ので、 +本スクリプトはノートブックの後始末セルより**前**に実行すること。 +既存バケットを使う運用(``.config.yaml`` に名前を書いた場合)はいつ実行してもよい。 +バケットは本スクリプトが作った multipart を中止するだけで、オブジェクトは残さない。 + ``--dry-run`` を付けると AWS には一切アクセスせず、呼び出し計画だけを印字する。 資格情報はコマンドライン引数では受け取らない(ps に出るため)。出力の直前に diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index 0cd1bab..c9536ca 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -45,13 +45,24 @@ "s3_access_key = None\n", "s3_secret_key = None\n", "\n", - "# S-1 / S-3 / S-5 用。通常バケット。s3_region_default に作ったもの\n", + "# バケットの扱い: **指定があればそれを使い(後始末で削除しない)、None なら\n", + "# 生成する(後始末で delete_bucket まで行う)**。既定は 3 つとも None = 生成。\n", + "# 既存バケットを使う場合は .config.yaml に 3 つとも書くこと。\n", + "# S-1 / S-3 / S-5 / S-7 用。通常バケット。s3_region_default に置く\n", "s3_bucket = None\n", - "# S-4 用。バージョニングを有効にしたバケット。s3_region_default に作ったもの\n", + "# S-4 用。バージョニングを有効にしたバケット。s3_region_default に置く\n", "s3_bucket_versioned = None\n", - "# S-2 用。s3_region_explicit に作ったもの\n", + "# S-2 用。s3_region_explicit に置く\n", "s3_bucket_apne1 = None\n", "\n", + "# 生成するときの名前は {bucket_prefix}-{bucket_suffix}-{std,ver,apne1}。\n", + "# bucket_suffix が None なら -<4 桁乱数> を実行時に決める\n", + "# (共有アカウントでの衝突と、前回の残骸を掴む事故を避けるため)。\n", + "bucket_prefix = 'e2e-sigv4'\n", + "bucket_suffix = None\n", + "# 後始末で生成バケットを消すか。False にすると残るので手で消すこと\n", + "delete_generated_buckets = True\n", + "\n", "# リージョンは管理画面でもアドオン画面でも入力しない(セル [5] の「リージョンの\n", "# 決まり方」参照)。この 2 つは boto3 のクライアント生成と証跡の記録にだけ使う。\n", "s3_region_default = 'us-east-1'\n", @@ -247,8 +258,8 @@ "- **対象 PR: RDM-waterbutler#93(`feature/s3-sigv4`, head `cf2f87ab`)/ RDM-osf.io#746**\n", "- 取る証跡: TEST_SPEC v1.8 の **P-6 / V-1 / I-2 / IF-3 / G-10** の実機証跡\n", "- 用意するテストデータ: URL 一覧、アカウント(既存ユーザー1: GRDM)、管理者アカウント、\n", - " AWS S3 のアクセスキー/シークレットキーと **3 つのバケット**\n", - " (通常 / バージョニング有効 / `ap-northeast-1`)\n", + " AWS S3 のアクセスキー/シークレットキー。**3 つのバケット(通常 / バージョニング有効 /\n", + " `ap-northeast-1`)はノートブックが生成し、後始末で削除する**(セル [6])\n", "\n", "## シナリオと受入項目の対応\n", "\n", @@ -257,7 +268,7 @@ "| S-0 環境確認 | IF-5 / R-5 | — | `S-0-jenkins-build.txt`(手動) / `S-0-params.txt` |\n", "| S-1 機関ストレージ登録・アップロード・ダウンロード | IF-3 既定 / **G-10** | 機関ストレージ | `S-1-wb-responses.json` / `S-1-download-redirect.json` |\n", "| S-2 明示リージョン(`ap-northeast-1`) | **IF-3** 明示 | 機関ストレージ | `S-2-wb-responses.json` |\n", - "| S-3 1000 件超フォルダ | **P-6** / R-6 | **S3 アドオン** | `S-3-listing-pages.json` / `S-3-cleanup.txt` |\n", + "| S-3 1000 件超フォルダ | **P-6** / R-6 | **S3 アドオン** | `S-3-listing-pages.json` |\n", "| S-4 バージョニング有効バケットでの削除 | **V-1**(任意で V-2) | **S3 アドオン** | `S-4-versions-before.json` / `S-4-versions-after.json` |\n", "| S-5 intra copy / move | **I-2** | **S3 アドオン** | `S-5-wb-responses.json` |\n", "| S-6 CompleteMultipartUpload の Error Code 採取 | E-1 | — | `scripts/s3_complete_error_codes.py`(本ノートブックの外) |\n", @@ -317,10 +328,20 @@ " Jenkins `r-deploy` のビルドログを `S-0-jenkins-build.txt` に手で保存する(S-0)。\n", "2. `.config.yaml`(リポジトリ直下、.gitignore 済み)に資格情報を書いておくか、\n", " 実行時のプロンプトに入力する。**ノートブックにキーを書かない**。\n", - "3. AWS のバケットを 3 つ用意する。いずれも空であること。\n", - " - 通常バケット(`s3_bucket`, `us-east-1`)\n", - " - バージョニング**有効**バケット(`s3_bucket_versioned`, `us-east-1`)\n", - " - `ap-northeast-1` のバケット(`s3_bucket_apne1`)\n", + "3. バケットは**用意しなくてよい**。セル [6] が次の 3 つを生成し、後始末で\n", + " `delete_bucket` まで行う(クォータ NB のセル [4] と同じ 1 ライフサイクル方式)。\n", + " - `{bucket_prefix}-{bucket_suffix}-std`(`s3_region_default`) — S-1 / S-3 / S-5 / S-7\n", + " - `{bucket_prefix}-{bucket_suffix}-ver`(`s3_region_default`、バージョニング有効) — S-4\n", + " - `{bucket_prefix}-{bucket_suffix}-apne1`(`s3_region_explicit`) — S-2\n", + "\n", + " 既存のバケットを使いたい場合は `.config.yaml` に `s3_bucket` /\n", + " `s3_bucket_versioned` / `s3_bucket_apne1` を書く。**指定したバケットは\n", + " 削除されない**(中身の残骸だけ消える)。\n", + " 必要な IAM 権限: `s3:CreateBucket` / `s3:PutBucketVersioning` /\n", + " `s3:GetBucketVersioning` / `s3:GetBucketLocation` / `s3:ListBucket` /\n", + " `s3:ListBucketVersions` / `s3:PutObject` / `s3:GetObject` /\n", + " `s3:DeleteObject` / `s3:DeleteObjectVersion` / `s3:AbortMultipartUpload` /\n", + " `s3:ListMultipartUploadParts` / `s3:DeleteBucket`\n", "4. 機関ストレージの登録は**上書き**である(後述)。実施前に合意を取ること。\n", "\n", "### 機関ストレージの登録は「追加」ではなく「上書き」である\n", @@ -333,6 +354,309 @@ "後始末チェックリスト(末尾)を必ず最後まで消化すること。" ] }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# バケットの用意と後始末を 1 ライフサイクルにする(**S-0 より前に必ず実行する**)\n", + "#\n", + "# クォータ NB(テスト手順-管理者機能-S3CompatSigV4-機関ストレージのクォータ.ipynb)\n", + "# のセル [4] と同じ方式。あちらは mc、こちらは boto3 で行う。\n", + "#\n", + "# - パラメータでバケット名を**指定した**場合: ユーザーのバケットを借りるので、\n", + "# 後始末では中身の残骸だけ消し、**バケット自体は消さない**。\n", + "# - **指定しなかった**場合: ここで作り、後始末で `delete_bucket` まで行う。\n", + "#\n", + "# 後始末は atexit に登録するので、以降のセルが例外で止まってもカーネル終了時に\n", + "# 走る。**カーネルが SIGKILL された場合は走らない。** そのときは証跡の\n", + "# S-0-buckets.json にある名前を見て、手で削除すること。\n", + "import atexit\n", + "import random\n", + "import re\n", + "import time\n", + "\n", + "from botocore.exceptions import ClientError\n", + "\n", + "# CreateBucket 〜 DeleteBucket までに必要な IAM 権限。AccessDenied のときに出す。\n", + "IAM_ACTIONS_NEEDED = (\n", + " 's3:CreateBucket, s3:PutBucketVersioning, s3:GetBucketVersioning, '\n", + " 's3:GetBucketLocation, s3:ListBucket, s3:ListBucketVersions, '\n", + " 's3:PutObject, s3:GetObject, s3:DeleteObject, s3:DeleteObjectVersion, '\n", + " 's3:AbortMultipartUpload, s3:ListMultipartUploadParts, s3:DeleteBucket')\n", + "\n", + "# 生成したバケット [(name, region), ...]。後始末はこれだけを対象にする。\n", + "generated_buckets = []\n", + "\n", + "\n", + "def s3_client(region):\n", + " \"\"\"boto3 の S3 クライアント。資格情報はノートブックのパラメータから取る。\"\"\"\n", + " import boto3\n", + " from botocore.config import Config\n", + " return boto3.client(\n", + " 's3',\n", + " region_name=region,\n", + " aws_access_key_id=s3_access_key,\n", + " aws_secret_access_key=s3_secret_key,\n", + " config=Config(signature_version='s3v4', retries={'max_attempts': 5}),\n", + " )\n", + "\n", + "\n", + "def _new_bucket_suffix():\n", + " return '{}-{:04d}'.format(datetime.now().strftime('%Y%m%d-%H%M%S'),\n", + " random.randrange(10000))\n", + "\n", + "\n", + "def _generated_name(role, suffix):\n", + " \"\"\"生成バケット名。S3 の命名規則(小文字・数字・ハイフン、3〜63 文字)に沿う。\"\"\"\n", + " name = f'{bucket_prefix}-{suffix}-{role}'.lower()\n", + " assert re.fullmatch(r'[a-z0-9][a-z0-9-]{1,61}[a-z0-9]', name), \\\n", + " f'S3 のバケット名として使えない: {name}(bucket_prefix / bucket_suffix を見直す)'\n", + " return name\n", + "\n", + "\n", + "def _create_bucket(name, region):\n", + " \"\"\"バケットを作る。戻り値 'created' / 'exists-own'。\n", + "\n", + " 他人所有の名前と衝突した場合は BucketAlreadyExists の ClientError を\n", + " そのまま投げる(呼び出し側が suffix を変えて 1 回だけ再試行する)。\n", + " \"\"\"\n", + " c = s3_client(region)\n", + " kwargs = {'Bucket': name}\n", + " # us-east-1 に CreateBucketConfiguration を付けると InvalidLocationConstraint\n", + " # になる。付けてよいのは us-east-1 以外のときだけ。\n", + " if region != 'us-east-1':\n", + " kwargs['CreateBucketConfiguration'] = {'LocationConstraint': region}\n", + " try:\n", + " c.create_bucket(**kwargs)\n", + " status = 'created'\n", + " except ClientError as e:\n", + " code = e.response['Error']['Code']\n", + " if code == 'BucketAlreadyOwnedByYou':\n", + " status = 'exists-own'\n", + " elif code == 'BucketAlreadyExists':\n", + " raise\n", + " elif code in ('AccessDenied', 'AllAccessDisabled'):\n", + " raise AssertionError(\n", + " f'CreateBucket({name}) が {code} で失敗した。'\n", + " f'必要な IAM 権限: {IAM_ACTIONS_NEEDED}')\n", + " else:\n", + " raise\n", + " c.get_waiter('bucket_exists').wait(Bucket=name)\n", + " return status\n", + "\n", + "\n", + "def _enable_versioning(name, region):\n", + " \"\"\"バージョニングを有効にし、get_bucket_versioning で Enabled を確認する。\"\"\"\n", + " c = s3_client(region)\n", + " try:\n", + " c.put_bucket_versioning(\n", + " Bucket=name, VersioningConfiguration={'Status': 'Enabled'})\n", + " except ClientError as e:\n", + " if e.response['Error']['Code'] in ('AccessDenied', 'AllAccessDisabled'):\n", + " raise AssertionError(\n", + " f'PutBucketVersioning({name}) が AccessDenied で失敗した。'\n", + " f'必要な IAM 権限: {IAM_ACTIONS_NEEDED}')\n", + " raise\n", + " for _ in range(10):\n", + " if c.get_bucket_versioning(Bucket=name).get('Status') == 'Enabled':\n", + " return 'Enabled'\n", + " time.sleep(1)\n", + " raise AssertionError(f'{name} のバージョニングが Enabled にならない(S-4 の前提)')\n", + "\n", + "\n", + "def _purge_bucket(name, region):\n", + " \"\"\"中身(全バージョン・削除マーカー・進行中の multipart)を空にする。\n", + "\n", + " list_object_versions は KeyMarker / VersionIdMarker で最後までたどる。\n", + " Prefix を付けないのは、生成バケットは丸ごと消すため。\n", + " \"\"\"\n", + " c = s3_client(region)\n", + " deleted = 0\n", + " kwargs = {'Bucket': name}\n", + " while True:\n", + " r = c.list_object_versions(**kwargs)\n", + " objs = [{'Key': v['Key'], 'VersionId': v['VersionId']}\n", + " for v in r.get('Versions', []) + r.get('DeleteMarkers', [])]\n", + " for i in range(0, len(objs), 1000):\n", + " c.delete_objects(Bucket=name, Delete={'Objects': objs[i:i + 1000]})\n", + " deleted += len(objs)\n", + " if not r.get('IsTruncated'):\n", + " break\n", + " kwargs['KeyMarker'] = r.get('NextKeyMarker')\n", + " kwargs['VersionIdMarker'] = r.get('NextVersionIdMarker')\n", + "\n", + " aborted = 0\n", + " kwargs = {'Bucket': name}\n", + " while True:\n", + " r = c.list_multipart_uploads(**kwargs)\n", + " for u in r.get('Uploads', []):\n", + " c.abort_multipart_upload(Bucket=name, Key=u['Key'],\n", + " UploadId=u['UploadId'])\n", + " aborted += 1\n", + " if not r.get('IsTruncated'):\n", + " break\n", + " kwargs['KeyMarker'] = r.get('NextKeyMarker')\n", + " kwargs['UploadIdMarker'] = r.get('NextUploadIdMarker')\n", + " return deleted, aborted\n", + "\n", + "\n", + "def cleanup_buckets(verbose=True):\n", + " \"\"\"このノートブックが作ったバケットを中身ごと削除する(シェルの trap 相当)。\n", + "\n", + " atexit に登録してあるので、以降のセルが例外で落ちてもカーネル終了時に走る。\n", + " delete_generated_buckets=False のときは消さずに名前を出す。\n", + " \"\"\"\n", + " lines = []\n", + " if not generated_buckets:\n", + " lines.append(' 生成したバケットは無い(すべてパラメータ指定)')\n", + " elif not delete_generated_buckets:\n", + " for name, region in generated_buckets:\n", + " lines.append(f' {name} ({region}): delete_generated_buckets=False の'\n", + " 'ため残した — **手動で削除すること**')\n", + " else:\n", + " for name, region in list(generated_buckets):\n", + " for attempt in (1, 2, 3):\n", + " try:\n", + " n, a = _purge_bucket(name, region)\n", + " s3_client(region).delete_bucket(Bucket=name)\n", + " lines.append(\n", + " f' {name} ({region}): {n} オブジェクト削除 / '\n", + " f'{a} multipart 中止 / delete_bucket OK(試行 {attempt})')\n", + " generated_buckets.remove((name, region))\n", + " break\n", + " except ClientError as e:\n", + " code = e.response['Error']['Code']\n", + " if code in ('NoSuchBucket', '404'):\n", + " lines.append(f' {name} ({region}): 既に無い')\n", + " generated_buckets.remove((name, region))\n", + " break\n", + " # 削除中に入った書き込みで空にならないことがある。一覧から\n", + " # やり直す(最大 3 回)。\n", + " if code == 'BucketNotEmpty' and attempt < 3:\n", + " continue\n", + " lines.append(f' {name} ({region}): {code} で削除できなかった'\n", + " ' — **手動で削除すること**')\n", + " break\n", + " text = '\\n'.join(lines)\n", + " if verbose:\n", + " print(text)\n", + " return text\n", + "\n", + "\n", + "atexit.register(cleanup_buckets, verbose=False)\n", + "\n", + "\n", + "def bucket_state(bucket, region, generated):\n", + " \"\"\"バケットの前提(所在・空であること・バージョニング)を採る。\"\"\"\n", + " c = s3_client(region)\n", + " listed = c.list_objects_v2(Bucket=bucket, MaxKeys=5)\n", + " # GetBucketLocation は us-east-1 を None(旧 API では '')で返す。\n", + " # WB の S3Provider._check_region も同じ解釈をする(IF-3 の根拠)。\n", + " raw = c.get_bucket_location(Bucket=bucket).get('LocationConstraint')\n", + " return {\n", + " 'bucket': bucket,\n", + " 'generated': generated,\n", + " 'declared_region': region,\n", + " 'GetBucketLocation_raw': raw,\n", + " 'GetBucketLocation': raw or 'us-east-1',\n", + " 'KeyCount': listed.get('KeyCount', 0),\n", + " 'sample_keys': [o['Key'] for o in listed.get('Contents', [])],\n", + " 'Versioning': c.get_bucket_versioning(Bucket=bucket).get(\n", + " 'Status', 'Disabled'),\n", + " }\n", + "\n", + "\n", + "# --- ここから実処理 ---------------------------------------------------------\n", + "if bucket_suffix is None:\n", + " bucket_suffix = _new_bucket_suffix()\n", + "\n", + "_used_by = {'std': ['S-1', 'S-3', 'S-5', 'S-7'], 'ver': ['S-4'], 'apne1': ['S-2']}\n", + "_region_of = {'std': s3_region_default, 'ver': s3_region_default,\n", + " 'apne1': s3_region_explicit}\n", + "_given = {'std': s3_bucket, 'ver': s3_bucket_versioned, 'apne1': s3_bucket_apne1}\n", + "_roles = ['std']\n", + "if run_versioned:\n", + " _roles.append('ver')\n", + "if run_apne1:\n", + " _roles.append('apne1')\n", + "\n", + "_resolved = {}\n", + "for _attempt in (1, 2):\n", + " _resolved = {}\n", + " try:\n", + " for _role in _roles:\n", + " _region = _region_of[_role]\n", + " if _given[_role]:\n", + " _resolved[_role] = (_given[_role], _region, False)\n", + " print(f' {_role}: {_given[_role]} ({_region}) -> 指定(削除しない)')\n", + " continue\n", + " _name = _generated_name(_role, bucket_suffix)\n", + " _status = _create_bucket(_name, _region)\n", + " generated_buckets.append((_name, _region))\n", + " _resolved[_role] = (_name, _region, True)\n", + " print(f' {_role}: {_name} ({_region}) -> {_status}')\n", + " break\n", + " except ClientError as _e:\n", + " if _e.response['Error']['Code'] != 'BucketAlreadyExists' or _attempt == 2:\n", + " raise\n", + " # 他人所有の名前と衝突した。作りかけを片付けて suffix を変え、1 回だけ再試行。\n", + " print('BucketAlreadyExists(他人所有の名前と衝突)。'\n", + " 'suffix を変えて 1 回だけ再試行する')\n", + " cleanup_buckets(verbose=False)\n", + " bucket_suffix = _new_bucket_suffix()\n", + "\n", + "assert len({_resolved[_r][0] for _r in _roles}) == len(_roles), \\\n", + " '3 つのバケットは別名でなければならない'\n", + "\n", + "s3_bucket = _resolved['std'][0]\n", + "if run_versioned:\n", + " s3_bucket_versioned = _resolved['ver'][0]\n", + " if _resolved['ver'][2]:\n", + " _enable_versioning(s3_bucket_versioned, _resolved['ver'][1])\n", + "if run_apne1:\n", + " s3_bucket_apne1 = _resolved['apne1'][0]\n", + "\n", + "# --- 前提の確認と証跡(旧セル [11] の内容をここに統合。重複して採らない)-------\n", + "_states = {_r: bucket_state(*_resolved[_r]) for _r in _roles}\n", + "for _r in _roles:\n", + " _states[_r]['role'] = _r\n", + " _states[_r]['used_by'] = _used_by[_r]\n", + " _s = _states[_r]\n", + " assert _s['GetBucketLocation'] == _s['declared_region'], (\n", + " f\"{_s['bucket']} の所在は {_s['GetBucketLocation']} で、\"\n", + " f\"宣言した {_s['declared_region']} と違う。\"\n", + " 'WB はバケットの所在でエンドポイントを決めるので、ここが食い違うと'\n", + " 'IF-3 の判定にならない')\n", + " if _s['generated']:\n", + " assert _s['KeyCount'] == 0, \\\n", + " f\"生成したはずの {_s['bucket']} に既にオブジェクトがある: {_s['sample_keys']}\"\n", + "if run_versioned:\n", + " assert _states['ver']['Versioning'] == 'Enabled', \\\n", + " f\"{s3_bucket_versioned} のバージョニングが有効でない(S-4 の前提)\"\n", + "\n", + "record('S-0-buckets.json', json.dumps({\n", + " 'bucket_prefix': bucket_prefix,\n", + " 'bucket_suffix': bucket_suffix,\n", + " 'delete_generated_buckets': delete_generated_buckets,\n", + " 'buckets': [_states[_r] for _r in _roles],\n", + " 'note': ('generated=true は本ノートブックが作ったバケットで、後始末で '\n", + " 'delete_bucket まで行う。generated=false はパラメータで指定された'\n", + " 'ユーザーのバケットで、中身の残骸だけ消してバケットは残す。'\n", + " 'GetBucketLocation_raw が null なのは us-east-1 のバケット。'\n", + " 'WB の S3Provider._check_region も同じ解釈をする(IF-3 の根拠)。'),\n", + "}, indent=2, ensure_ascii=False))\n", + "\n", + "print()\n", + "print('=' * 74)\n", + "print(' S-6(scripts/s3_complete_error_codes.py)を実行するときは次を貼ること')\n", + "print('=' * 74)\n", + "print(f'export E2E_S3_BUCKET={s3_bucket}')\n", + "print(f'export E2E_S3_REGION={s3_region_default}')\n", + "print('=' * 74)\n" + ], + "execution_count": null, + "outputs": [] + }, { "cell_type": "code", "id": "08959fba-527e-4083-8913-b776df7a40b0", @@ -494,20 +818,7 @@ " return time.time() - started\n", " await asyncio.sleep(2)\n", " raise AssertionError(\n", - " f'{filename} のアップロードが {timeout / 1000:.0f} 秒で終わらない')\n", - "\n", - "\n", - "def s3_client(region):\n", - " \"\"\"boto3 の S3 クライアント。資格情報はノートブックのパラメータから取る。\"\"\"\n", - " import boto3\n", - " from botocore.config import Config\n", - " return boto3.client(\n", - " 's3',\n", - " region_name=region,\n", - " aws_access_key_id=s3_access_key,\n", - " aws_secret_access_key=s3_secret_key,\n", - " config=Config(signature_version='s3v4', retries={'max_attempts': 5}),\n", - " )" + " f'{filename} のアップロードが {timeout / 1000:.0f} 秒で終わらない')\n" ], "execution_count": null, "outputs": [] @@ -539,7 +850,10 @@ " `helm upgrade -i rdm-osf ... --set image.tag=`\n", " — そのビルドが実際に staging2 に載ったことの証明\n", "\n", - "`wb_deployed_sha` / `osf_deployed_sha` に手で控えて、次のセルで証跡に残す。" + "`wb_deployed_sha` / `osf_deployed_sha` に手で控えて、次のセルで証跡に残す。\n", + "\n", + "バケットの前提(所在・バージョニング・空であること)は**セル [6]** が\n", + "`S-0-buckets.json` に書いている。ここでは重複して採らない。" ] }, { @@ -593,55 +907,6 @@ "execution_count": null, "outputs": [] }, - { - "cell_type": "code", - "id": "c439e246-6dda-4862-a551-a88235913f4a", - "metadata": { - "tags": [] - }, - "source": [ - "# S-0: バケットの前提を boto3 で確認する(空であること・リージョン・バージョニング)\n", - "def bucket_region(bucket):\n", - " loc = s3_client(s3_region_default).get_bucket_location(Bucket=bucket)\n", - " # GetBucketLocation は us-east-1 を None(旧 API では '') で返す\n", - " return loc.get('LocationConstraint') or 'us-east-1'\n", - "\n", - "\n", - "def bucket_state(bucket, region):\n", - " c = s3_client(region)\n", - " listed = c.list_objects_v2(Bucket=bucket, MaxKeys=5)\n", - " ver = c.get_bucket_versioning(Bucket=bucket)\n", - " return {\n", - " 'bucket': bucket,\n", - " 'declared_region': region,\n", - " 'GetBucketLocation': bucket_region(bucket),\n", - " 'KeyCount': listed.get('KeyCount', 0),\n", - " 'sample_keys': [o['Key'] for o in listed.get('Contents', [])],\n", - " 'Versioning': ver.get('Status', 'Disabled'),\n", - " }\n", - "\n", - "\n", - "_states = [bucket_state(s3_bucket, s3_region_default)]\n", - "if run_apne1:\n", - " _states.append(bucket_state(s3_bucket_apne1, s3_region_explicit))\n", - "if run_versioned:\n", - " _states.append(bucket_state(s3_bucket_versioned, s3_region_default))\n", - "\n", - "for _s in _states:\n", - " assert _s['GetBucketLocation'] == _s['declared_region'], (\n", - " f\"{_s['bucket']} の所在は {_s['GetBucketLocation']} で、\"\n", - " f\"宣言した {_s['declared_region']} と違う。\"\n", - " 'WB はバケットの所在でエンドポイントを決めるので、ここが食い違うと'\n", - " 'IF-3 の判定にならない')\n", - "if run_versioned:\n", - " assert _states[-1]['Versioning'] == 'Enabled', \\\n", - " 's3_bucket_versioned のバージョニングが有効になっていない(S-4 の前提)'\n", - "\n", - "record('S-0-buckets.json', json.dumps(_states, indent=2, ensure_ascii=False))" - ], - "execution_count": null, - "outputs": [] - }, { "cell_type": "markdown", "id": "1cb8783b-8e0c-4497-8967-f856f66f1fe2", @@ -1256,6 +1521,10 @@ "`PermanentRedirect` は「署名したホストと送ったホストが食い違っている」ときに\n", "S3 が返すコードで、エンドポイント固定が壊れたときの典型的な症状である。\n", "\n", + "`s3_bucket_apne1` はセル [6] が `{bucket_prefix}-{bucket_suffix}-apne1` として\n", + "`CreateBucketConfiguration={'LocationConstraint': s3_region_explicit}` 付きで\n", + "生成したもの(パラメータで指定した場合はそのバケット)。\n", + "\n", "別プロジェクトを作って実施する。S-1 のプロジェクトは `us-east-1` のバケットに\n", "本体を置いた版を持っており、登録を差し替えたあとに同じプロジェクトを使うと\n", "どちらのバケットを見ているのか証跡上あいまいになるため。" @@ -1549,6 +1818,9 @@ "1. `toomany/` の中身が `0001` 〜 `{toomany_count}` まで**重複・欠落なく**表示される\n", "2. 一覧応答の `next_token` が連鎖し、**最終ページで空**になる\n", "\n", + "seed 先の `s3_bucket` はセル [6] が生成した\n", + "`{bucket_prefix}-{bucket_suffix}-std`(パラメータで指定した場合はそのバケット)。\n", + "\n", "`toomany/` の中身は boto3 の `put_object`(空本文)で作る。UI からの 1500 回\n", "アップロードは時間がかかるうえ、検証したいのは一覧のページングだけなので\n", "直接置く。" @@ -1797,6 +2069,11 @@ "**DeleteMarker を積む**。PR #77 の仕様は「UI の削除でその Key の\n", "`Versions` も `DeleteMarkers` も残さない」こと。\n", "\n", + "対象は `s3_bucket_versioned` = セル [6] が生成した\n", + "`{bucket_prefix}-{bucket_suffix}-ver`。生成時に `put_bucket_versioning` で\n", + "`Enabled` にし、`get_bucket_versioning` で確認済み(パラメータで指定した場合は\n", + "既に `Enabled` であることを確認するだけで、設定は変更しない)。\n", + "\n", "手順: 同名ファイルを UI から 3 回アップロード(上書き)→\n", "`list_object_versions` で 3 版以上あることを確認 → UI から削除 →\n", "`list_object_versions` を取り直して **両方 0** であることを確認。" @@ -2445,7 +2722,8 @@ " 'S-2': '明示リージョン (IF-3)', 'S-3': '1000件超一覧 (P-6/R-6)',\n", " 'S-4': 'バージョン削除 (V-1)', 'S-5': 'intra copy/move (I-2)',\n", " 'S-6': 'CompleteMultipartUpload Error Code (E-1)',\n", - " 'S-7': 'チャンクアップロード (E-2/K-4)'}\n", + " 'S-7': 'チャンクアップロード (E-2/K-4)',\n", + " 'S-9': '後始末(バケット削除)'}\n", "for _f in _files:\n", " _key = _f[:3]\n", " _index.append(f'| `{_f}` | {os.path.getsize(os.path.join(run_dir, _f))} | '\n", @@ -2564,10 +2842,17 @@ "tags": [] }, "source": [ - "# バケットの残骸を消す(toomany / S-4 / S-5 / S-7)\n", + "# 後始末。\n", + "# - パラメータで**指定された**バケット(ユーザー所有)は、本テストが置いた残骸\n", + "# (toomany/ / S-4 / S-5 / S-7)だけを消す。バケット自体は消さない。\n", + "# - セル [6] が**生成した**バケットは cleanup_buckets() が中身ごと delete_bucket する。\n", "_cleanup = []\n", "\n", "\n", + "def _is_generated(bucket):\n", + " return any(_b == bucket for _b, _r in generated_buckets)\n", + "\n", + "\n", "def _delete_prefix(bucket, prefix, region):\n", " c = s3_client(region)\n", " deleted = 0\n", @@ -2602,26 +2887,42 @@ " return deleted\n", "\n", "\n", - "if run_toomany:\n", - " _n = _delete_prefix(s3_bucket, toomany_prefix, s3_region_default)\n", - " _cleanup.append(f'{toomany_prefix}: {_n} 件削除')\n", - "if run_intra:\n", - " for _p in (intra_copy_dest, intra_move_dest):\n", - " _n = _delete_prefix(s3_bucket, _p, s3_region_default)\n", - " _cleanup.append(f'{_p}: {_n} 件削除')\n", - " _n = _delete_prefix(s3_bucket, s5_file_name, s3_region_default)\n", - " _cleanup.append(f'{s5_file_name}: {_n} 件削除')\n", - "if run_chunked:\n", - " _n = _delete_prefix(s3_bucket, s7_file_name, s3_region_default)\n", - " _cleanup.append(f'{s7_file_name}: {_n} 件削除')\n", - "if run_versioned:\n", - " _n = _delete_all_versions(s3_bucket_versioned, s4_file_name, s3_region_default)\n", - " _cleanup.append(f'{s4_file_name} (全版): {_n} 件削除')\n", + "if _is_generated(s3_bucket):\n", + " _cleanup.append(f'{s3_bucket}: 生成バケットなので残骸削除は省略'\n", + " '(このあと delete_bucket する)')\n", + "else:\n", + " if run_toomany:\n", + " _n = _delete_prefix(s3_bucket, toomany_prefix, s3_region_default)\n", + " _cleanup.append(f'{toomany_prefix}: {_n} 件削除')\n", + " if run_intra:\n", + " for _p in (intra_copy_dest, intra_move_dest):\n", + " _n = _delete_prefix(s3_bucket, _p, s3_region_default)\n", + " _cleanup.append(f'{_p}: {_n} 件削除')\n", + " _n = _delete_prefix(s3_bucket, s5_file_name, s3_region_default)\n", + " _cleanup.append(f'{s5_file_name}: {_n} 件削除')\n", + " if run_chunked:\n", + " _n = _delete_prefix(s3_bucket, s7_file_name, s3_region_default)\n", + " _cleanup.append(f'{s7_file_name}: {_n} 件削除')\n", + " _left = s3_client(s3_region_default).list_objects_v2(\n", + " Bucket=s3_bucket, MaxKeys=20)\n", + " _cleanup.append(f'{s3_bucket} の残り: KeyCount={_left.get(\"KeyCount\", 0)} '\n", + " f'{[o[\"Key\"] for o in _left.get(\"Contents\", [])]}')\n", "\n", - "_left = s3_client(s3_region_default).list_objects_v2(Bucket=s3_bucket, MaxKeys=20)\n", - "_cleanup.append(f's3_bucket の残り: KeyCount={_left.get(\"KeyCount\", 0)} '\n", - " f'{[o[\"Key\"] for o in _left.get(\"Contents\", [])]}')\n", - "record('S-3-cleanup.txt', '\\n'.join(_cleanup))" + "if run_versioned:\n", + " if _is_generated(s3_bucket_versioned):\n", + " _cleanup.append(f'{s3_bucket_versioned}: 生成バケットなので残骸削除は省略')\n", + " else:\n", + " _n = _delete_all_versions(s3_bucket_versioned, s4_file_name,\n", + " s3_region_default)\n", + " _cleanup.append(f'{s4_file_name} (全版): {_n} 件削除')\n", + "\n", + "_text = '\\n'.join(['--- 指定バケット(ユーザー所有)の残骸削除 ---']\n", + " + (_cleanup or [' 対象なし'])\n", + " + ['', '--- 生成バケットの削除 ---', cleanup_buckets(verbose=True)])\n", + "record('S-9-cleanup.txt', _text)\n", + "# INDEX.md はセル [67] で作っているので、S-9-cleanup.txt を載せるには\n", + "# このあとセル [67] をもう一度実行すること。\n", + "print('\\n[!] INDEX.md に S-9-cleanup.txt を載せるにはセル [67] を再実行する')" ], "execution_count": null, "outputs": [] @@ -2643,13 +2944,37 @@ " (`*-institutional` / `*-apne1` / `*-addon` / `*-versioned`)\n", "- [ ] ユーザー設定 →「アドオンアカウント構成」から S3 アカウントの連携を解除する\n", " (アドオンのアカウントは**ユーザー単位**で残る)\n", - "- [ ] バケットが空になっていることを確認する(上のセルの `S-3-cleanup.txt`)\n", + "- [ ] バケットが空になっていることを確認する(上のセルの `S-9-cleanup.txt`)\n", + "- [ ] **セル [6] が生成した 3 バケットが消えている**ことを確認する\n", + " (次のセル。`list_buckets` に `{bucket_prefix}-{bucket_suffix}-*` が無いこと)\n", "- [ ] `S-0-jenkins-build.txt` を手で保存したか確認する\n", "- [ ] 証跡ディレクトリを `aws-s3-sigv4-improvement/evidence/e2e/` 配下に置いたか確認する\n", "- [ ] S-6(`scripts/s3_complete_error_codes.py`)を実行し、\n", " `S-6-complete-error-codes.json` を同じ証跡ディレクトリに入れる" ] }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# 生成バケットが消えていることの確認(後始末チェックリストの最後の項目)\n", + "_all = [b['Name'] for b in\n", + " s3_client(s3_region_default).list_buckets().get('Buckets', [])]\n", + "_left = [b for b in _all if b.startswith(f'{bucket_prefix}-{bucket_suffix}')]\n", + "print(f'list_buckets: {len(_all)} 件')\n", + "if not delete_generated_buckets:\n", + " print('delete_generated_buckets=False。**次のバケットを手で削除すること**:')\n", + " for _b in _left:\n", + " print(' -', _b)\n", + "else:\n", + " assert not _left, (\n", + " f'生成したバケットが残っている: {_left}。'\n", + " 'cleanup_buckets() の出力(S-9-cleanup.txt)を見て手で削除すること')\n", + " print(f'{bucket_prefix}-{bucket_suffix}-* は残っていない')\n" + ], + "execution_count": null, + "outputs": [] + }, { "cell_type": "code", "id": "d063bc4b-4b14-4ba6-aaa5-f595a0f2cc0f", From 0eb574e57f60a6b2703747a5ef32b93be616dbab Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 1 Oct 2026 11:30:43 +0900 Subject: [PATCH 10/19] Fix failure-artifact capture in run_pw / _finish_pw_context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E の実行中に、失敗の理由が読めない・後始末が終わらない事象が出たので直す。 - run_pw: 例外を捕まえた直後に元の例外を stderr に出す。DOM ダンプと スクリーンショットの採取が先だと、ページが navigating のまま固まったときに 採取側で時間を使い切り、何で落ちたのかが残らない - run_pw: _save_screenshot の例外を握り潰す。ここで上げると元の例外が 差し替わる。あわせて last_path を渡し忘れていたのを渡す - _save_screenshot: full_page=False、timeout=10000。失敗時の証跡なので 全画面である必要はなく、full_page=True はページ全体をスクロールしながら 描画するため固まったページでは既定の 30 秒を使い切る - _finish_pw_context: current_contexts の更新が [::-1](反転)で要素が減らず、 末尾の再帰が同じコンテキストを処理し続けていた。[:-1] にする。 実際には rmtree 済みの temp_dir を読みに行って FileNotFoundError で止まる - _finish_pw_context: temp_dir は全コンテキストで共有しているので、残りを 処理し終えてから rmtree する。video-N.webm の N は _video_index_offset で 通し番号にする(コンテキストごとに 1 から振り直すと前の分を上書きする) --- scripts/playwright.py | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/scripts/playwright.py b/scripts/playwright.py index fb88e3c..573b1d4 100644 --- a/scripts/playwright.py +++ b/scripts/playwright.py @@ -75,6 +75,11 @@ async def run_pw(f, last_path=default_last_path, screenshot=True, permissions=No try: next_page = await f(current_pages[-1]) except: + # 証跡採取より先に主信号を出す。ページが navigating のまま固まると + # DOM ダンプもスクリーンショットも待たされるので、後回しにすると + # 「何で落ちたのか」が読めないまま採取側の例外だけが残る。 + print('操作が失敗しました。元の例外は次のとおり。', file=sys.stderr) + traceback.print_exc() try: await _save_dom(last_path=last_path) except Exception: @@ -84,7 +89,13 @@ async def run_pw(f, last_path=default_last_path, screenshot=True, permissions=No await finish_pw_context(screenshot=screenshot, last_path=last_path) raise if screenshot: - await _save_screenshot() + # 採取に失敗しても握り潰す。ここで例外を上げると + # 元の例外が差し替わって失敗理由が消える。 + try: + await _save_screenshot(last_path=last_path) + except Exception: + print('スクリーンショットの取得に失敗しました。', file=sys.stderr) + traceback.print_exc() raise if next_page is not None: current_pages.append(next_page) @@ -173,7 +184,11 @@ async def _save_screenshot(last_path=None): if current_pages is None or len(current_pages) == 0: return screenshot_path = os.path.join(temp_dir, 'last-screenshot.png') - await current_pages[-1].screenshot(path=screenshot_path, full_page=True) + # 失敗時の証跡なので全画面である必要はない。full_page=True はページ全体を + # スクロールしながら描画するため、ページが固まっていると既定の 30 秒を + # 使い切る。短い timeout で撮れるものだけ撮る。 + await current_pages[-1].screenshot( + path=screenshot_path, full_page=False, timeout=10000) dest_screenshot_path = os.path.join(last_path or default_last_path, 'last-screenshot.png') shutil.copyfile(screenshot_path, dest_screenshot_path) print(f'Screenshot: {dest_screenshot_path}') @@ -199,7 +214,7 @@ async def _save_dom(last_path=None): fh.write('\n\n'.join(parts)) print(f'DOM: {dest_dom_path}') -async def _finish_pw_context(screenshot=False, last_path=None): +async def _finish_pw_context(screenshot=False, last_path=None, _video_index_offset=0): global current_contexts if current_contexts is None or len(current_contexts) == 0: return @@ -215,10 +230,14 @@ async def _finish_pw_context(screenshot=False, last_path=None): timeout_on_screenshot = True if timeout_on_screenshot: return - current_contexts = current_contexts[::-1] + # 処理済みのコンテキストをスタックから外す。close_latest_page():129 と同じ形。 + # ここが [::-1](反転)だと要素が減らず、末尾の再帰呼び出しが同じコンテキストを + # 何度も処理する。実際には rmtree 済みの temp_dir を読みに行って + # FileNotFoundError になることで止まっていた。 + current_contexts = current_contexts[:-1] await current_context.close() for i, current_page in enumerate(current_pages): - index = i + 1 + index = _video_index_offset + i + 1 try: video_path = await current_page.video.path() dest_video_path = os.path.join(last_path or default_last_path, f'video-{index}.webm') @@ -242,12 +261,19 @@ async def _finish_pw_context(screenshot=False, last_path=None): for msg in console_messages: f.write(f"{msg['timestamp']:.3f} {msg['url']} [{msg['type']}] {msg['text']}\n") print(f'Console: {console_log_path}') - shutil.rmtree(temp_dir) for page in current_pages: await page.close() - if len(current_contexts) == 0: + if len(current_contexts) > 0: + # temp_dir は全コンテキストで共有しており、動画も har.zip もこの下にある。 + # 残りを処理し終えるまで消さない。video-N.webm の N は通し番号にする + # (コンテキストごとに 1 から振り直すと前のコンテキスト分を上書きする)。 + await _finish_pw_context( + screenshot=False, + last_path=last_path, + _video_index_offset=_video_index_offset + len(current_pages), + ) return - await _finish_pw_context(screenshot=False, last_path=last_path) + shutil.rmtree(temp_dir) async def mock_clipboard(page): """Mock navigator.clipboard for non-secure contexts.""" From d966ac7ea3ee447d2e3857e0b974725c079beedf Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 1 Oct 2026 11:31:22 +0900 Subject: [PATCH 11/19] =?UTF-8?q?test(s3):=20=E3=83=AD=E3=82=B0=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=83=BB=E3=82=A2=E3=83=89=E3=82=AA=E3=83=B3=E6=8E=A5?= =?UTF-8?q?=E7=B6=9A=E3=83=BB=E8=A8=BC=E8=B7=A1=E3=83=AC=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=80=E3=82=92=E5=AE=9F=E6=A9=9F=E3=81=AE=E6=8C=99=E5=8B=95?= =?UTF-8?q?=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit staging2 での実行(run-20260928-153426 / run-20260930-143654)で止まった箇所を直す。 - 管理者ログイン: admin_idp_entity_id が無い環境では GakuNin Embedded DS (「所属している機関を選択」)が出る。これまでは entityID 必須で落としていたが、 embedded-wayf.js の window.inc_search_list から admin_idp_name で entityID を 引いて選べるようにした。一致しないと alert が出て送信されないだけなので、 押す前に JS 側の一覧で確かめる - ダッシュボード: wait_dashboard_loaded を追加。[新規プロジェクト作成]は 一覧より先に出るので、それだけを待つと「読み込み中」と「プロジェクトが無い」を 区別できず、ensure_project_exists が同名プロジェクトを二重に作る。 スピナーのままや Ember の「APIは利用できません」はリロードして待ち直す - 機関ストレージのノード: 表示名は管理画面の #storage_name で決まり、S-1 の登録は 値が入っていれば上書きしない(セル [18])ため、staging2 では既存の 'E2E Quota MinIO (S3CompatSigV4)' が残っていてパラメータと食い違っていた。 osfstorage のアイコンでノードを特定して実名を読む open_institutional_storage を追加し、食い違いは黙って合わせず必ず出力する - S3 アドオンの接続: [有効にする]の click ハンドラは project-addons-page.js が バンドル読み込み後に bind するので、visible になった瞬間に押すと捨てられる。 jQuery._data でハンドラが付くまで待つ。[プロフィールからアカウントをインポート]も knockout の importAuth バインド待ちなので、出るまで押し直す。 既に有効・既に接続済みの場合も掛け直せるようにし、どれにも当たらなければ落とす - 応答レコーダ: セル再実行時の二重登録を防ぐ。GET 以外は request_body を残す (action の "move"/"copy" は要求本文にしかなく、応答の links.move は 成否に関係なく必ずあるので判定に使えない)。一覧ツールバーのダウンロード URL に 乗る OSF の accessToken(`?token=`)を証跡からマスクする - HAR: 取り直しで 2 回目のセッションを回すと finish_pw_context が同じ場所へ 上書きするので、既存の har.zip を har-N.zip に退避してから閉じる --- ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 283 ++++++++++++++++-- 1 file changed, 254 insertions(+), 29 deletions(-) diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index c9536ca..ffa6f9c 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -706,8 +706,19 @@ "WB_BODY_LIMIT = 2000\n", "WB_BODY_LIMIT_META = 4 * 1024 * 1024\n", "\n", + "# 既にレコーダを付けた page。このセルを再実行しても二重登録しないための目印。\n", + "# セル自体の再実行で消えないよう、未定義のときだけ作る。\n", + "try:\n", + " _recorder_attached\n", + "except NameError:\n", + " _recorder_attached = []\n", + "\n", "\n", "async def _attach_recorder(page):\n", + " if any(p is page for p in _recorder_attached):\n", + " print('[i] 応答レコーダは登録済み(セルの再実行)。二重に付けない')\n", + " return\n", + "\n", " async def _on_response(response):\n", " url = response.url\n", " if '/v1/resources/' not in url and '/wb/' not in url:\n", @@ -718,6 +729,10 @@ " 'url': url,\n", " 'at': datetime.now().isoformat(),\n", " }\n", + " if response.request.method != 'GET':\n", + " # action: \"move\" / \"copy\" は *要求* 本文にしかない。応答の\n", + " # links.move は成否に関係なく必ずあるので判定には使えない。\n", + " entry['request_body'] = (response.request.post_data or '')[:WB_BODY_LIMIT]\n", " limit = WB_BODY_LIMIT_META if 'meta=' in url else WB_BODY_LIMIT\n", " try:\n", " entry['body'] = (await response.text())[:limit]\n", @@ -726,6 +741,7 @@ " wb_responses.append(entry)\n", "\n", " page.on('response', lambda r: asyncio.ensure_future(_on_response(r)))\n", + " _recorder_attached.append(page)\n", "\n", "\n", "async def _step(page):\n", @@ -744,6 +760,9 @@ " out = re.sub(r'(X-Amz-(?:Signature|Credential|Security-Token)=)[^&\\s\"\\\\]+',\n", " r'\\1***', out)\n", " out = re.sub(r'AKIA[0-9A-Z]{12,}', 'AKIA***', out)\n", + " # 一覧ツールバーのダウンロード URL には OSF の accessToken が `?token=` で\n", + " # 乗る(fangorn の buildTreeBeardDownload)。証跡には出さない。\n", + " out = re.sub(r'([?&]token=)[^&\\s\"\\\\]+', r'\\1***', out)\n", " return out\n", "\n", "\n", @@ -772,12 +791,51 @@ "\n", "async def open_storage(page, storage_name):\n", " \"\"\"ファイルツリーのストレージノードを開き、アップロードボタンが出る状態にする。\"\"\"\n", - " await page.locator(f'//*[contains(text(), \"{storage_name}\")]').first.click()\n", + " # contains(text(), ...) は説明文などにも当たるので title-text に限定する。\n", + " # click の既定 30 秒は transition_timeout(60 秒)より短いので明示する。\n", + " node = page.locator(\n", + " '//*[contains(@class, \"title-text\")'\n", + " f' and starts-with(normalize-space(.), \"{storage_name}\")]').first\n", + " await expect(node).to_be_visible(timeout=transition_timeout)\n", + " await node.click(timeout=transition_timeout)\n", " await asyncio.sleep(2)\n", " upload_btn = page.locator('//i[contains(@class, \"fa-upload\")]/../*[text() = \"アップロード\"]')\n", " await expect(upload_btn).to_be_visible(timeout=transition_timeout)\n", "\n", "\n", + "async def resolve_institutional_storage_name(page):\n", + " \"\"\"ファイルツリーの機関ストレージノードの表示名を読む。\n", + "\n", + " 表示名は管理画面の #storage_name の値で決まるので環境ごとに違う。S-1 の\n", + " 登録は既に値が入っていれば上書きしない(セル [18])ため、staging2 では\n", + " 既存の 'E2E Quota MinIO (S3CompatSigV4)' がそのまま残っていた。\n", + " パラメータの名前で探すと環境依存で落ちるので、ノードのアイコン\n", + " (osfstorage の comicon_custom_storage.png)で特定して実名を読む。\n", + " \"\"\"\n", + " title = page.locator(\n", + " '//*[contains(@class, \"tb-td-first\")]'\n", + " '//*[contains(@style, \"comicon_custom_storage.png\")]'\n", + " '/../../following-sibling::*[contains(@class, \"title-text\")]').first\n", + " await expect(title).to_be_visible(timeout=transition_timeout)\n", + " return (await title.inner_text()).strip()\n", + "\n", + "\n", + "async def open_institutional_storage(page):\n", + " \"\"\"機関ストレージのノードを開く。\n", + "\n", + " 表示名はパラメータではなく実際のノードから取る。食い違っていたら黙って\n", + " 合わせるのではなく必ず出力する(機関やストレージの取り違えを隠さない)。\n", + " \"\"\"\n", + " global institutional_storage_name\n", + " name = await resolve_institutional_storage_name(page)\n", + " if name != institutional_storage_name:\n", + " print('[!] 機関ストレージの表示名がパラメータと違う: '\n", + " f'パラメータ={institutional_storage_name!r} / 実際={name!r}'\n", + " ' -> 実際の表示名で進める')\n", + " institutional_storage_name = name\n", + " await open_storage(page, name)\n", + "\n", + "\n", "async def clear_growls(page):\n", " \"\"\"残っている growl を消す。\n", "\n", @@ -964,18 +1022,14 @@ "\n", "async def admin_login(page):\n", " email_field = page.locator('#id_email')\n", - " if await email_field.count() > 0 and await email_field.is_visible():\n", + " django_form = await email_field.count() > 0 and await email_field.is_visible()\n", + " if django_form:\n", " # Django メール/パスワードフォーム(enable_form=True の環境)\n", " await email_field.fill(admin_username)\n", " await page.locator('#id_password').fill(admin_password)\n", " await page.locator('//form//button[@type=\"submit\"]').click()\n", - " else:\n", + " elif admin_idp_entity_id is not None:\n", " # entityID 直接指定で Shibboleth ログインを開始(Embedded DS バイパス)\n", - " if admin_idp_entity_id is None:\n", - " raise ValueError(\n", - " 'admin_idp_entity_id が未設定です。管理者ログインがメール/パスワード形式でない場合は '\n", - " '.config.yaml または本ノートブックのパラメータで対象環境のIdP entityIDを指定してください。'\n", - " )\n", " base = admin_rdm_url.rstrip('/')\n", " url = (base + '/Shibboleth.sso/Login'\n", " '?entityID=' + urllib.parse.quote(admin_idp_entity_id, safe='')\n", @@ -986,7 +1040,45 @@ " title = await page.title()\n", " assert 'Unknown Identity Provider' not in title, (\n", " f'SPが entityID を認識していません: {admin_idp_entity_id}')\n", - "\n", + " else:\n", + " # GakuNin Embedded DS(「所属している機関を選択」)から IdP を選ぶ。\n", + " # embedded-wayf.js の submitForm() は #keytext(候補をクリックした\n", + " # 場合は hiddenKeyText)を IdP 名と突き合わせ、hidden の #user_idp に\n", + " # entityID を入れてから送信する。entityID が入るのは**[選択]を押した\n", + " # 時**なので、押す前に #user_idp を見ても空で正しい。\n", + " # 名前が一致しないと alert('正しい所属機関を選んで下さい') が出て\n", + " # 送信されないだけなので、先に JS の一覧を引いて確かめる。\n", + " assert admin_idp_name is not None, (\n", + " '管理者ログインがメール/パスワード形式ではない(Embedded DS が出ている)。'\n", + " '.config.yaml に admin_idp_name(例: GakuNin RDM IdP)を書くか、'\n", + " 'admin_idp_entity_id に IdP の entityID を直接指定すること。')\n", + " _js = ('(name) => { const n = name.toLowerCase();'\n", + " ' for (const e of (window.inc_search_list || []))'\n", + " ' for (let j = 3; j < e.length; j++)'\n", + " ' if (String(e[j]).toLowerCase() === n) return e[0];'\n", + " ' return null; }')\n", + " entity_id = await page.evaluate(_js, admin_idp_name)\n", + " assert entity_id, (\n", + " f'Embedded DS の IdP 一覧に {admin_idp_name!r} が無い。'\n", + " '.config.yaml の admin_idp_name を一覧の表記に合わせること。')\n", + " print(f'[1] Embedded DS: {admin_idp_name} -> entityID={entity_id}')\n", + " print(' 次回からは .config.yaml に admin_idp_entity_id を書けば'\n", + " ' DS を省ける(外部サイト依存が減る)')\n", + " keytext = page.locator('#keytext')\n", + " await expect(keytext).to_be_editable(timeout=transition_timeout)\n", + " await keytext.click()\n", + " await keytext.fill('')\n", + " # incsearch(suggest.js)の[選択]ボタンは keyEvent -> checkLoop ->\n", + " # search -> checkNoMatch の順でしか disabled が外れず、その起点である\n", + " # checkLoop のタイマは**キーイベントでしか起動しない**(suggest.js\n", + " # L505-507)。fill() は input イベントしか出さないのでボタンは\n", + " # disabled のままになる。必ず 1 文字ずつ打つこと。\n", + " await keytext.type(admin_idp_name, delay=50)\n", + " wayf_submit = page.locator('#wayf_submit_button')\n", + " await expect(wayf_submit).to_be_enabled(timeout=transition_timeout)\n", + " await wayf_submit.click()\n", + "\n", + " if not django_form:\n", " username = page.locator('#username')\n", " j_username = page.locator('input[name=\"j_username\"]')\n", " consent = page.locator('#_shib_idp_doNotRememberConsent')\n", @@ -1341,13 +1433,55 @@ "tags": [] }, "source": [ + "async def wait_dashboard_loaded(page, retries=3):\n", + " \"\"\"ダッシュボードのプロジェクト一覧が読み終わるのを待つ。\n", + "\n", + " [新規プロジェクト作成] ボタンは一覧より先に出るので、それだけを待つと\n", + " 「読み込み中」と「プロジェクトが無い」を区別できない。読み込み中のまま\n", + " ensure_project_exists を呼ぶと「無い」と誤判定して同名プロジェクトを\n", + " 二重に作ってしまうので、必ずこれを先に通すこと。\n", + " 一覧は 429 などでスピナーのまま止まったり、Ember が\n", + " 「APIは利用できません」(data-analytics-scope=\"No api page\")を出したり\n", + " するので、いずれもリロードして待ち直す\n", + " (scripts/grdm.py の expect_dashboard が同じ理由でリトライしている)。\n", + " \"\"\"\n", + " loaded = page.locator(\n", + " '//*[@data-test-dashboard-item-title]'\n", + " ' | //*[contains(text(), \"まだプロジェクトがありません\")]').first\n", + " create_btn = page.locator('//*[@data-test-create-project-modal-button]')\n", + " no_api = page.locator('//*[@data-analytics-scope = \"No api page\"]')\n", + " for attempt in range(1, retries + 1):\n", + " reason = None\n", + " try:\n", + " # No api page ではボタンが描画されないので、どちらが出るかを待つ。\n", + " # ボタンだけを待つと毎回 transition_timeout を無駄にする。\n", + " await expect(create_btn.or_(no_api).first\n", + " ).to_be_visible(timeout=transition_timeout)\n", + " if await no_api.count() > 0:\n", + " reason = 'Ember が「APIは利用できません」を出している'\n", + " else:\n", + " await expect(loaded).to_be_visible(timeout=transition_timeout)\n", + " return\n", + " except AssertionError:\n", + " reason = 'プロジェクト一覧がスピナーのまま'\n", + " if attempt == retries:\n", + " raise AssertionError(\n", + " f'ダッシュボードが読み込まれない({reason})。'\n", + " ' 429 か API の一時停止の可能性がある。'\n", + " ' ブラウザで手動で開けるか確認すること。'\n", + " f' 現在地: {page.url}') from None\n", + " print(f'[!] {reason}。再読込する ({attempt}/{retries - 1})')\n", + " await asyncio.sleep(30)\n", + " await page.goto(rdm_url)\n", + "\n", + "\n", "async def open_project(page, project_name):\n", " \"\"\"ダッシュボードから対象プロジェクトを開いてファイルタブを表示する。\"\"\"\n", " await page.goto(rdm_url)\n", - " await expect(page.locator('//*[@data-test-create-project-modal-button]')\n", - " ).to_be_visible(timeout=transition_timeout)\n", + " await wait_dashboard_loaded(page)\n", " await page.locator(\n", - " f'//*[@data-test-dashboard-item-title and text()=\"{project_name}\"]').click()\n", + " f'//*[@data-test-dashboard-item-title and text()=\"{project_name}\"]'\n", + " ).first.click(timeout=transition_timeout)\n", " await expect(page.locator('#projectNavFiles')).to_be_visible(timeout=transition_timeout)\n", "\n", "\n", @@ -1359,8 +1493,7 @@ "\n", "async def create_project_and_open_files(page, project_name):\n", " await page.goto(rdm_url)\n", - " await expect(page.locator('//*[@data-test-create-project-modal-button]')\n", - " ).to_be_visible(timeout=transition_timeout)\n", + " await wait_dashboard_loaded(page)\n", " await scripts.grdm.ensure_project_exists(page, project_name, transition_timeout)\n", " await open_project(page, project_name)\n", " await open_files_tab(page)\n", @@ -1404,7 +1537,7 @@ }, "source": [ "async def _step(page):\n", - " await open_storage(page, institutional_storage_name)\n", + " await open_institutional_storage(page)\n", " await scripts.grdm.upload_file(page, s1_file_path)\n", " await wait_for_upload_finished(page, s1_file_name)\n", " await expect(page.locator(f'//*[contains(text(), \"{s1_file_name}\")]')\n", @@ -1571,7 +1704,7 @@ "\n", " async def _step(page):\n", " await create_project_and_open_files(page, rdm_project_apne1)\n", - " await open_storage(page, institutional_storage_name)\n", + " await open_institutional_storage(page)\n", " await scripts.grdm.upload_file(page, s2_file_path)\n", " await wait_for_upload_finished(page, s2_file_name)\n", " await expect(page.locator(f'//*[contains(text(), \"{s2_file_name}\")]')\n", @@ -1677,6 +1810,45 @@ "tags": [] }, "source": [ + "async def open_import_account_dialog(page, link, connect_btn, attempts=5):\n", + " \"\"\"[プロフィールからアカウントをインポート]を押し、[接続]が出るまで粘る。\n", + "\n", + " このリンクの click は knockout の `click: importAuth` バインド\n", + " (oauthAddonNodeConfig.js)。アドオンを有効にした直後は mako がサーバ側で\n", + " 描画した HTML だけが先に見えていて applyBindings がまだ走っていないことが\n", + " あり、その状態で押すとクリックは捨てられる。何も起きないまま[接続]を\n", + " 待ち続けて落ちる(run-20260930-143654 の症状。最終 DOM に bootbox が無く、\n", + " リンクだけが出たままだった)。バインドの有無を覗くより押し直すほうが確実。\n", + " \"\"\"\n", + " await expect(link).to_be_visible(timeout=transition_timeout)\n", + " for i in range(attempts):\n", + " await link.click(timeout=transition_timeout)\n", + " try:\n", + " await expect(connect_btn).to_be_enabled(timeout=10000)\n", + " return\n", + " except AssertionError:\n", + " print(f'[i] [接続]のダイアログが出ない({i + 1} 回目)。押し直す')\n", + " raise AssertionError(\n", + " '[プロフィールからアカウントをインポート]を'\n", + " f' {attempts} 回押しても[接続]のダイアログが出ない')\n", + "\n", + "\n", + "async def confirm_import_account(page, connect_btn):\n", + " \"\"\"[接続]を押す。アカウントが複数あるときは何を選ぶのかを出す。\n", + "\n", + " ユーザーに S3 アカウントが 2 つ以上あると bootbox.confirm ではなく\n", + " bootbox.prompt になり、select の既定は accounts()[0] になる\n", + " (oauthAddonNodeConfig.js の importAuth)。パラメータの s3_access_key と\n", + " 同じ保証は無いので、黙って進めずに候補を出す。\n", + " \"\"\"\n", + " select = page.locator('//select[contains(@class, \"bootbox-input-select\")]')\n", + " if await select.count() > 0:\n", + " print('[!] S3 アカウントが複数ある。既定(先頭)のまま接続する:',\n", + " await select.locator('option').all_inner_texts())\n", + " await asyncio.sleep(1)\n", + " await connect_btn.click(timeout=transition_timeout)\n", + "\n", + "\n", "async def connect_s3_addon(page, project_name, bucket):\n", " \"\"\"プロジェクトに S3 アドオンを接続し、ファイルページを開く。\n", "\n", @@ -1688,13 +1860,36 @@ "\n", " # アドオンタブ → 有効にする → 確認\n", " await page.locator('//a[text() = \"アドオン\"]').click()\n", + " container = page.locator(f'//div[@full_name = \"{addon_storage_name}\"]').first\n", + " await expect(container).to_be_visible(timeout=transition_timeout)\n", " enable_link = page.locator(\n", " f'//div[@full_name = \"{addon_storage_name}\"]//descendant::a[text() = \"有効にする\"]')\n", - " await expect(enable_link).to_be_visible(timeout=transition_timeout)\n", - " await enable_link.click()\n", - " confirm = page.locator('//button[@data-bb-handler = \"confirm\"]')\n", - " await expect(confirm).to_be_visible(timeout=transition_timeout)\n", - " await confirm.click()\n", + " # 途中で落ちたセルを掛け直せるように、既に有効なら有効化は飛ばす\n", + " # (有効なときは同じ場所が[無効にする]になっていて、押すと外れてしまう)。\n", + " if await enable_link.count() == 0:\n", + " print(f'[i] {addon_storage_name} は既に有効。有効化は飛ばす')\n", + " else:\n", + " # [有効にする]の は mako がサーバ側で描画しており、bootbox の\n", + " # 確認ダイアログを出す click ハンドラは project-addons-page.js が\n", + " # バンドル読み込み後に bind する($('.addon-container').each ->\n", + " # elm.find('a').bind('click', ...))。visible になった瞬間に押すと\n", + " # クリックが捨てられてダイアログが出ないので、ハンドラが付くまで待つ。\n", + " # jQuery は CDN の global(webpack.common.config.js の externals)。\n", + " await page.wait_for_function(\n", + " '(name) => {'\n", + " ' const jq = window.jQuery; if (!jq) return false;'\n", + " ' for (const b of document.getElementsByClassName(\"addon-container\")) {'\n", + " ' if (b.getAttribute(\"full_name\") !== name) continue;'\n", + " ' const a = b.querySelector(\"a\"); if (!a) return false;'\n", + " ' const ev = jq._data(a, \"events\");'\n", + " ' return !!(ev && ev.click && ev.click.length);'\n", + " ' }'\n", + " ' return false; }',\n", + " arg=addon_storage_name, timeout=transition_timeout)\n", + " await enable_link.click()\n", + " confirm = page.locator('//button[@data-bb-handler = \"confirm\"]')\n", + " await expect(confirm).to_be_visible(timeout=transition_timeout)\n", + " await confirm.click()\n", " await asyncio.sleep(2)\n", "\n", " # 資格情報の入力欄が出たら入力して保存する(初回のみ)\n", @@ -1702,7 +1897,15 @@ " connect_link = page.locator(\n", " f'//img[@src = \"/static/addons/{addon_storage_id}/comicon.png\"]/..'\n", " f'//a[contains(text(), \"アカウントに接続する\")]')\n", - " import_link = page.locator('//a[contains(text(), \"プロフィールからアカウントをインポート\")]')\n", + " # 同じ文言のリンクは他のアドオンにもあるので、このアドオンの中に限る。\n", + " import_link = page.locator(\n", + " f'//*[@id = \"{addon_storage_id}Scope\"]'\n", + " '//a[contains(text(), \"プロフィールからアカウントをインポート\")]')\n", + " # [接続]は bootbox の確認ボタン。ラベルは oauthAddonNodeConfig.js の\n", + " # importAuth が渡す `_('Import')` で、ja の訳が「接続」(js_messages.po)。\n", + " # 押し直しでダイアログが重なることがあるので一番手前を見る。\n", + " connect_btn = page.locator('//button[text() = \"接続\"]').last\n", + " new_bucket = page.locator('//button[@id = \"newBucket\"]')\n", "\n", " if await connect_link.count() > 0:\n", " await connect_link.first.click()\n", @@ -1715,16 +1918,28 @@ " await page.locator(\n", " f'//*[@id = \"{addon_storage_id}InputCredentials\"]//input[@id = \"secret_key\"]'\n", " ).fill(s3_secret_key)\n", + " # 保存に成功すると s3NodeConfig.js の connectAccount が続けて\n", + " # importAuth() を呼ぶので、インポートと同じ[接続]に合流する。\n", " await input_creds.locator('.btn-success').click()\n", + " await expect(connect_btn).to_be_enabled(timeout=transition_timeout)\n", + " await confirm_import_account(page, connect_btn)\n", " elif await import_link.count() > 0:\n", - " await import_link.first.click()\n", - " await asyncio.sleep(2)\n", + " await open_import_account_dialog(page, import_link.first, connect_btn)\n", + " await confirm_import_account(page, connect_btn)\n", + " elif await new_bucket.is_visible():\n", + " # 前回の中断などで既にアカウントが紐づいている。onImportSuccess の\n", + " # activatePicker を通らないのでピッカーは閉じたまま。自分で開く。\n", + " print('[i] 既にアカウントが接続済み。バケット選択から続ける')\n", + " await page.locator(\n", + " '//button[.//span[text() = \"変更\" or text() = \"Change\"]]').click(\n", + " timeout=transition_timeout)\n", + " else:\n", + " raise AssertionError(\n", + " 'アドオン構成に[アカウントに接続する]も[プロフィールから'\n", + " 'アカウントをインポート]も無く、バケットの選択も出ていない。'\n", + " 'アドオンの有効化が効いていない可能性がある')\n", "\n", - " # 接続 → バケット選択 → 保存\n", - " connect_btn = page.locator('//button[text() = \"接続\"]')\n", - " await expect(connect_btn).to_be_enabled(timeout=transition_timeout)\n", - " await asyncio.sleep(1)\n", - " await connect_btn.click()\n", + " # バケット選択 → 保存\n", " await expect(page.locator('//button[@id = \"newBucket\"]')).to_be_visible(\n", " timeout=transition_timeout)\n", " await page.locator(\n", @@ -2744,6 +2959,16 @@ }, "source": [ "# Playwright を閉じる。**これをしないと HAR が書き出されない。**\n", + "# 既に har.zip があるなら退避する。finish_pw_context は同じ場所に上書きするので、\n", + "# 取り直しのために 2 回目のセッションを回すと 1 回目の HAR が消える。\n", + "_har = os.path.join(run_dir, 'har.zip')\n", + "if os.path.exists(_har):\n", + " _n = 1\n", + " while os.path.exists(os.path.join(run_dir, f'har-{_n}.zip')):\n", + " _n += 1\n", + " shutil.copyfile(_har, os.path.join(run_dir, f'har-{_n}.zip'))\n", + " print(f'既存の HAR を har-{_n}.zip に退避した')\n", + "\n", "await finish_pw_context(timeout=300)" ], "execution_count": null, From 718cf5eeee4d8008610412e30b90808d627d6144 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 1 Oct 2026 11:31:49 +0900 Subject: [PATCH 12/19] =?UTF-8?q?test(s3):=20=E3=83=90=E3=82=B1=E3=83=83?= =?UTF-8?q?=E3=83=88=E7=94=9F=E6=88=90=E3=81=AE=E5=85=A5=E5=8F=A3=E3=82=92?= =?UTF-8?q?=E6=95=B4=E7=90=86=E3=81=97=E3=80=81=E8=B3=87=E6=A0=BC=E6=83=85?= =?UTF-8?q?=E5=A0=B1=E3=81=AE=E5=88=87=E3=82=8A=E5=88=86=E3=81=91=E3=82=92?= =?UTF-8?q?=E5=85=88=E3=81=AB=E8=A1=8C=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - バケット名を input() で聞くのをやめた。既定は 3 つとも生成なので、聞かれた側は Enter で流すしかなく、誤って既存バケット名を入れると後始末で削除されない側に 倒れる。指定したいときは .config.yaml に書く(セル [1] が補完する)。 どれが生成でどれが指定かをセルの出力に並べる - CreateBucket の前に sts:GetCallerIdentity で資格情報だけを確かめる。 この API は IAM 権限を要らないので「鍵が違う」と「権限が足りない」を切り分け られる。S3 互換ストレージ(MinIO)の鍵を .config.yaml に残したまま実行して CreateBucket の InvalidAccessKeyId で初めて気づく、という回り道を無くす。 アカウント ID / ARN は証跡にも出力にも残さない - CreateBucket の InvalidAccessKeyId / SignatureDoesNotMatch / InvalidClientTokenId にも同じ案内を出す - atexit への cleanup_buckets 登録をセル再実行で二重にしない - S-2 の判定: GetBucketLocation は us-east-1 を None で返すので、生の値を GetBucketLocation_raw に残し、表示は `or 'us-east-1'` に揃える (セル [6] の bucket_state、および WB の _check_region と同じ解釈) - 後始末チェックリストの後ろに cleanup_buckets() を手で叩くためのセルを追加 --- ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 87 ++++++++++++++++--- 1 file changed, 73 insertions(+), 14 deletions(-) diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index ffa6f9c..2b6b495 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -170,18 +170,25 @@ " s3_access_key = input(prompt='S3 Access Key')\n", "if s3_secret_key is None:\n", " s3_secret_key = getpass(prompt='S3 Secret Key')\n", - "if s3_bucket is None:\n", - " s3_bucket = input(prompt=f'S3 Bucket Name (通常 / {s3_region_default})')\n", - "if run_apne1 and s3_bucket_apne1 is None:\n", - " s3_bucket_apne1 = input(prompt=f'S3 Bucket Name (明示リージョン / {s3_region_explicit})')\n", - "if run_versioned and s3_bucket_versioned is None:\n", - " s3_bucket_versioned = input(prompt=f'S3 Bucket Name (バージョニング有効 / {s3_region_default})')\n", - "\n", - "# バケット名は証跡には残すが、ここでは長さだけ確認する(取り違え防止)\n", - "assert len({b for b in [s3_bucket, s3_bucket_apne1, s3_bucket_versioned] if b}) == \\\n", - " len([b for b in [s3_bucket, s3_bucket_apne1, s3_bucket_versioned] if b]), \\\n", - " '3 つのバケットは別名でなければならない'\n", - "(len(s3_access_key), len(s3_secret_key), len(s3_bucket))" + "\n", + "# バケット名は**聞かない**。None のままならセル [6] が生成し、後始末で削除する。\n", + "# 既存のバケットを使う場合は .config.yaml に s3_bucket / s3_bucket_versioned /\n", + "# s3_bucket_apne1 を書く(セル [1] が補完する)。指定したバケットは削除されない。\n", + "_switch = {'s3_bucket': True, 's3_bucket_versioned': run_versioned,\n", + " 's3_bucket_apne1': run_apne1}\n", + "_given = {_k: _v for _k, _v in [('s3_bucket', s3_bucket),\n", + " ('s3_bucket_versioned', s3_bucket_versioned),\n", + " ('s3_bucket_apne1', s3_bucket_apne1)] if _v}\n", + "assert len(set(_given.values())) == len(_given), \\\n", + " f'3 つのバケットは別名でなければならない: {_given}'\n", + "for _k in _switch:\n", + " if _k in _given:\n", + " print(f'{_k} = {_given[_k]}(指定。後始末で削除しない)')\n", + " elif _switch[_k]:\n", + " print(f'{_k} = None -> セル [6] が生成し、後始末で削除する')\n", + " else:\n", + " print(f'{_k} = None -> 対象シナリオが無効なので作らない')\n", + "(len(s3_access_key), len(s3_secret_key))" ], "execution_count": null, "outputs": [] @@ -356,6 +363,7 @@ }, { "cell_type": "code", + "id": "8529de19", "metadata": {}, "source": [ "# バケットの用意と後始末を 1 ライフサイクルにする(**S-0 より前に必ず実行する**)\n", @@ -401,6 +409,29 @@ " )\n", "\n", "\n", + "def _check_credentials():\n", + " \"\"\"CreateBucket の前に、資格情報が AWS に通ることだけを確かめる。\n", + "\n", + " sts:GetCallerIdentity は IAM 権限を必要としないので、\n", + " 「鍵が違う」のか「権限が足りない」のかを切り分けられる。\n", + " アカウント ID / ARN は証跡にも出力にも残さない。\n", + " \"\"\"\n", + " import boto3\n", + " _sts = boto3.client('sts', region_name=s3_region_default,\n", + " aws_access_key_id=s3_access_key,\n", + " aws_secret_access_key=s3_secret_key)\n", + " try:\n", + " _sts.get_caller_identity()\n", + " except ClientError as e:\n", + " raise AssertionError(\n", + " 'S3 の資格情報が AWS に通らない'\n", + " f'({e.response[\"Error\"][\"Code\"]})。'\n", + " '.config.yaml の s3_access_key / s3_secret_key が **AWS** の'\n", + " 'ものか確認すること。MinIO など S3 互換ストレージの鍵では通らない'\n", + " '(s3_endpoint_url が play.min.io などになっていないか)。') from None\n", + " print(' 資格情報 (sts:GetCallerIdentity): OK')\n", + "\n", + "\n", "def _new_bucket_suffix():\n", " return '{}-{:04d}'.format(datetime.now().strftime('%Y%m%d-%H%M%S'),\n", " random.randrange(10000))\n", @@ -439,6 +470,12 @@ " raise AssertionError(\n", " f'CreateBucket({name}) が {code} で失敗した。'\n", " f'必要な IAM 権限: {IAM_ACTIONS_NEEDED}')\n", + " elif code in ('InvalidAccessKeyId', 'SignatureDoesNotMatch',\n", + " 'InvalidClientTokenId'):\n", + " raise AssertionError(\n", + " f'CreateBucket({name}) が {code} で失敗した。'\n", + " '資格情報が **AWS** のものか確認すること'\n", + " '(S3 互換ストレージの鍵では通らない)。')\n", " else:\n", " raise\n", " c.get_waiter('bucket_exists').wait(Bucket=name)\n", @@ -543,7 +580,11 @@ " return text\n", "\n", "\n", - "atexit.register(cleanup_buckets, verbose=False)\n", + "# セルを再実行しても二重に登録しない。atexit が持つのは古い関数オブジェクトだが、\n", + "# 参照する generated_buckets はノートブックの名前空間なので後始末は効く。\n", + "if not globals().get('_cleanup_registered'):\n", + " atexit.register(cleanup_buckets, verbose=False)\n", + " _cleanup_registered = True\n", "\n", "\n", "def bucket_state(bucket, region, generated):\n", @@ -567,6 +608,8 @@ "\n", "\n", "# --- ここから実処理 ---------------------------------------------------------\n", + "_check_credentials()\n", + "\n", "if bucket_suffix is None:\n", " bucket_suffix = _new_bucket_suffix()\n", "\n", @@ -1768,10 +1811,14 @@ " # 置くのでキー名はハッシュになる。件数と所在だけ見る)\n", " _c = s3_client(s3_region_explicit)\n", " _listed = _c.list_objects_v2(Bucket=s3_bucket_apne1, MaxKeys=10)\n", + " # GetBucketLocation は us-east-1 を None で返す。セル [6] の\n", + " # bucket_state と同じ解釈にする(WB の _check_region も同じ)。\n", + " _raw_loc = _c.get_bucket_location(Bucket=s3_bucket_apne1).get('LocationConstraint')\n", " _bad = wb_failures()\n", " _verdict = {\n", " 'bucket': s3_bucket_apne1,\n", - " 'GetBucketLocation': bucket_region(s3_bucket_apne1),\n", + " 'GetBucketLocation_raw': _raw_loc,\n", + " 'GetBucketLocation': _raw_loc or 'us-east-1',\n", " 'KeyCount': _listed.get('KeyCount', 0),\n", " 'wb_responses': len(wb_responses),\n", " 'failures': _bad,\n", @@ -3230,6 +3277,18 @@ ], "execution_count": null, "outputs": [] + }, + { + "cell_type": "code", + "id": "cb3bfb45-1e74-4a31-99e2-0316cc8e88ef", + "metadata": { + "tags": [] + }, + "source": [ + "cleanup_buckets()" + ], + "execution_count": null, + "outputs": [] } ], "metadata": { From 69ffb10739d6e6d88586d92d9599ba25b9642166 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 1 Oct 2026 11:32:11 +0900 Subject: [PATCH 13/19] =?UTF-8?q?test(s3):=20G-10=20=E3=81=AE=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=82=92=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E4=B8=80?= =?UTF-8?q?=E8=A6=A7=E3=81=AE=E3=83=84=E3=83=BC=E3=83=AB=E3=83=90=E3=83=BC?= =?UTF-8?q?=E3=81=AE=E3=83=80=E3=82=A6=E3=83=B3=E3=83=AD=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ファイル詳細画面の[ダウンロード]では G-10(presigned URL への 302)を 原理的に踏めないことが分かったので、押す場所と判定を作り直す。 詳細画面のボタンは ``(filepage/index.js L522)で、飛び先は OSF の `//download`。そこが `direct=None` を入れた WB URL に redirect する (addons/base/views.py L984)。WB 側は `accept_url='direct' not in query_arguments` (metadata.py L84)なので accept_url=False になり、presigned を返さず WB 自身が ストリームする(= 200)。`direct` を付けないのはファイル一覧のツールバーの [ダウンロード]だけ(fangorn.js L1641 buildTreeBeardDownload、足すのは token のみ)。 - S-1 のダウンロードは一覧に戻ってツールバーの[ダウンロード]を押す。 ツールバーのボタンは選択行(tb.multiselected())に対して出るので先に行を選ぶ。 フォルダの「Zip 形式でダウンロード」も fa-download なので span の文字で絞る - 主信号を HAR から応答イベント(s1_g10_redirects)に変更。セル [9] のレコーダは ヘッダを残さず Location が取れず、HAR はダウンロードのリダイレクト経路を 落とすことがあるため。HAR は補助として残す - 判定の候補から `direct` 付きと `revisions=`(版一覧)を外す。混ぜると 「302 が出ない = 決定-19 が効いていない」という誤った結論になる - 失敗時のメッセージを「G-10 の経路を踏んでいない」と「踏んだのに 302 に ならない」で言い分ける。取り違えると PR の欠陥を捏造することになる。 判定材料として has_direct を全エントリに残す - S-2 のダウンロードも同じツールバーのボタンに揃える --- ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 164 +++++++++++++++--- 1 file changed, 141 insertions(+), 23 deletions(-) diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index 2b6b495..9f77f21 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -1618,13 +1618,39 @@ "source": [ "### S-1 (G-10): ダウンロードする\n", "\n", - "ファイル詳細画面の「ダウンロード」を押し、ブラウザのダウンロードイベントを待つ。\n", + "**ファイル一覧のツールバーの[ダウンロード]**を押し、ブラウザのダウンロード\n", + "イベントを待つ。\n", "\n", "WB は `accept_url` が真のとき presigned URL を返し、ハンドラが 302 でそこへ飛ばす\n", "(`waterbutler/server/api/v1/provider/metadata.py` の `get_file` →\n", "`if isinstance(stream, str): return self.redirect(stream)`)。\n", - "**302 と `Location` の中身は HAR でしか取れない**ので、判定は末尾のセルで行う\n", - "(HAR は `finish_pw_context` のときに書き出される)。" + "\n", + "#### ファイル詳細画面の[ダウンロード]では G-10 を踏めない\n", + "\n", + "詳細画面のボタンは ``(`website/static/js/filepage/index.js`\n", + "L522)で、飛び先は OSF の `//download`。そこが\n", + "\n", + "```python\n", + "return redirect(file_node.generate_waterbutler_url(\n", + " **dict(extras, direct=None, version=version.identifier, ...)))\n", + "```\n", + "\n", + "(RDM-osf.io `addons/base/views.py` L984)と **`direct` を付けた** WB URL に飛ばす。\n", + "WB 側は\n", + "\n", + "```python\n", + "accept_url='direct' not in self.request.query_arguments\n", + "```\n", + "\n", + "(`metadata.py` L84)なので `accept_url=False` になり、presigned を返さず\n", + "**WB 自身がストリームする(= 200)**。つまりこの経路では 302 は原理的に出ない。\n", + "\n", + "`direct` を付けないのはファイル一覧のツールバーの[ダウンロード]だけ\n", + "(`fangorn.js` L1641 `window.location = waterbutler.buildTreeBeardDownload(item)`、\n", + "`buildTreeBeardDownload` は `token` しか足さない)。判定にはこちらを使う。\n", + "\n", + "`Location` の中身は応答イベントで拾って `s1_g10_redirects` に残す。HAR は\n", + "ダウンロードのリダイレクト経路を落とすことがあるので補助にとどめる。" ] }, { @@ -1635,26 +1661,92 @@ }, "source": [ "s1_download_path = None\n", + "s1_g10_redirects = []\n", + "\n", + "\n", + "def redirect_entry(url, status, loc):\n", + " return {\n", + " 'url': url,\n", + " 'status': status,\n", + " # `direct` 付きの要求は WB が自分でストリームする仕様なので 302 に\n", + " # ならない(metadata.py L84)。「経路を踏んでいない」と「経路は踏んだが\n", + " # 302 にならない」を取り違えないために必ず残す。\n", + " 'has_direct': bool(re.search(r'[?&]direct(=|&|$)', url)),\n", + " 'location_host': loc.split('/')[2] if '://' in loc else '',\n", + " 'location_is_amazonaws': 'amazonaws.com' in loc,\n", + " 'location_has_signature': 'X-Amz-Signature' in loc,\n", + " 'location_has_sigv4_algorithm': 'AWS4-HMAC-SHA256' in loc,\n", + " 'location': re.sub(r'(X-Amz-(?:Signature|Credential|Security-Token)=)'\n", + " r'[^&\\s]+', r'\\1***', loc),\n", + " }\n", + "\n", + "\n", + "async def capture_wb_redirects(page, out):\n", + " \"\"\"WB の 3xx を `Location` ごと拾う。\n", + "\n", + " セル [9] のレコーダはヘッダを残さないので Location が取れない。HAR も\n", + " ダウンロードのリダイレクト経路を落とすことがあるため、G-10 の主信号は\n", + " これにする。\n", + " \"\"\"\n", + " async def _on_response(response):\n", + " if '/v1/resources/' not in response.url:\n", + " return\n", + " if response.status not in (301, 302, 303, 307, 308):\n", + " return\n", + " try:\n", + " loc = (await response.all_headers()).get('location', '')\n", + " except Exception as e:\n", + " loc = f''\n", + " out.append(redirect_entry(response.url, response.status, loc))\n", + "\n", + " page.on('response', lambda r: asyncio.ensure_future(_on_response(r)))\n", + "\n", + "\n", + "def fangorn_download_button(page):\n", + " \"\"\"ファイル一覧のツールバーの[ダウンロード]。\n", + "\n", + " fangorn.js の FGItemButtons(L2726-2733)が FGButton(L2602-2625)で\n", + " `
\n", + " ダウンロード
` を出す。フォルダの「Zip 形式で\n", + " ダウンロード」も fa-download なので span の文字で絞る。\n", + " \"\"\"\n", + " return page.locator(\n", + " '//*[contains(@class, \"fangorn-toolbar-icon\")]'\n", + " '[.//i[contains(@class, \"fa-download\")]]'\n", + " '[.//span[text() = \"ダウンロード\"]]')\n", + "\n", "\n", "async def _step(page):\n", " global s1_download_path\n", " download_dir = os.path.join(work_dir, 'downloaded')\n", " os.makedirs(download_dir, exist_ok=True)\n", + " await capture_wb_redirects(page, s1_g10_redirects)\n", "\n", - " btn = page.locator(\n", - " '//i[contains(@class, \"fa-download\")]/..'\n", - " '/*[text() = \"ダウンロード\" or text() = \"Download\"]')\n", - " if await btn.count() == 0:\n", - " btn = page.locator('//a[contains(@href, \"?action=download\")]')\n", + " # 詳細画面の[ダウンロード]は OSF が `direct` を付けるので 302 にならない。\n", + " # ファイル一覧に戻ってツールバーの[ダウンロード]を押す(上の説明を参照)。\n", + " await open_project(page, rdm_project_inst)\n", + " await open_files_tab(page)\n", + " await open_institutional_storage(page)\n", + " # ツールバーのボタンは選択中の行(tb.multiselected())に対して出る。\n", + " await grdm.get_select_file_extension_locator(page, s1_file_name).click(\n", + " timeout=transition_timeout)\n", + " btn = fangorn_download_button(page)\n", + " await expect(btn).to_be_visible(timeout=transition_timeout)\n", " async with page.expect_download(timeout=transition_timeout * 3) as download_info:\n", - " await btn.first.click()\n", + " # click の既定 30 秒は transition_timeout より短いので明示する\n", + " await btn.click(timeout=transition_timeout)\n", " download = await download_info.value\n", " s1_download_path = os.path.join(download_dir, download.suggested_filename)\n", " await download.save_as(s1_download_path)\n", " print('downloaded:', s1_download_path, os.path.getsize(s1_download_path), 'bytes')\n", + " await asyncio.sleep(2)\n", "\n", "await run_pw(_step)\n", "\n", + "for _r in s1_g10_redirects:\n", + " print(_r['status'], _r['location_host'], 'sig=', _r['location_has_signature'],\n", + " 'direct=', _r['has_direct'])\n", + "\n", "with open(s1_file_path) as _a, open(s1_download_path) as _b:\n", " assert _a.read() == _b.read(), 'ダウンロードした内容がアップロードしたものと違う'\n", "print('S-1: アップロードした内容と一致')" @@ -1777,13 +1869,11 @@ " await asyncio.sleep(3)\n", " download_dir = os.path.join(work_dir, 'downloaded')\n", " os.makedirs(download_dir, exist_ok=True)\n", - " btn = page.locator(\n", - " '//i[contains(@class, \"fa-download\")]/..'\n", - " '/*[text() = \"ダウンロード\" or text() = \"Download\"]')\n", - " if await btn.count() == 0:\n", - " btn = page.locator('//a[contains(@href, \"?action=download\")]')\n", + " # locator の根拠は S-1 のセルのコメントを見ること\n", + " btn = page.locator('//*[@id = \"toggleBar\"]//a[contains(@class, \"file-download\")]')\n", + " await expect(btn).to_be_visible(timeout=transition_timeout)\n", " async with page.expect_download(timeout=transition_timeout * 3) as download_info:\n", - " await btn.first.click()\n", + " await btn.click(timeout=transition_timeout)\n", " download = await download_info.value\n", " s2_download_path = os.path.join(download_dir, download.suggested_filename)\n", " await download.save_as(s2_download_path)\n", @@ -3026,7 +3116,7 @@ "id": "a9746476-3c4f-46d2-aee1-10346bdebe03", "metadata": {}, "source": [ - "### S-1 (G-10): HAR からダウンロードのリダイレクトを判定する\n", + "### S-1 (G-10): ダウンロードのリダイレクトを判定する\n", "\n", "WB の `download_file` は `accept_url` が真のとき presigned URL を文字列で返し、\n", "ハンドラが `self.redirect(stream)` する\n", @@ -3035,9 +3125,16 @@ "**判定**: ダウンロード要求に対する応答が **302** で、`Location` が\n", "`amazonaws.com` かつ `X-Amz-Signature` を含むこと(= SigV4 の presigned URL)。\n", "\n", + "主信号は S-1 のダウンロードで拾った `s1_g10_redirects`(応答イベント)、\n", + "HAR は補助。\n", + "\n", "パスの `providers/` の後ろは、機関ストレージ経由なら `osfstorage`、\n", "アドオン経由なら `s3` になる(セル [5] の表のとおり、`osfstorage` は\n", - "`download` だけは内側の `s3` に委譲するので、どちらでも 302 になる)。" + "`download` だけは内側の `s3` に委譲するので、どちらでも 302 になる)。\n", + "\n", + "`direct` 付きの要求と `revisions=`(版一覧)は候補から外す。前者は WB が\n", + "自分でストリームする仕様で 302 にならず、後者はそもそもダウンロードではない。\n", + "ここを混ぜると「302 が出ない = 決定-19 が効いていない」と**誤った結論**を出す。" ] }, { @@ -3067,7 +3164,9 @@ " continue\n", " if not re.search(r'/providers/(osfstorage|s3)/', _req['url']):\n", " continue\n", - " if 'meta=' in _req['url'] or 'zip=' in _req['url']:\n", + " # `revisions=` は版一覧でダウンロードではない。\n", + " if ('meta=' in _req['url'] or 'zip=' in _req['url']\n", + " or 'revisions=' in _req['url']):\n", " continue\n", " _loc = ''\n", " for _h in _res.get('headers', []):\n", @@ -3076,6 +3175,9 @@ " _redirects.append({\n", " 'url': _req['url'],\n", " 'status': _res['status'],\n", + " # `direct` 付きは WB が自分でストリームする仕様なので 302 にならない\n", + " # (metadata.py L84)。判定の読み違いを防ぐために必ず残す。\n", + " 'has_direct': bool(re.search(r'[?&]direct(=|&|$)', _req['url'])),\n", " 'location_host': _loc.split('/')[2] if '://' in _loc else '',\n", " 'location_is_amazonaws': 'amazonaws.com' in _loc,\n", " 'location_has_signature': 'X-Amz-Signature' in _loc,\n", @@ -3084,18 +3186,34 @@ " r'[^&\\s]+', r'\\1***', _loc),\n", " })\n", "\n", - " _ok = [r for r in _redirects\n", + " # 主信号は S-1 のダウンロードで拾った応答イベント(Location 付き)。\n", + " try:\n", + " _live = list(s1_g10_redirects)\n", + " except NameError:\n", + " _live = []\n", + " _ok = [r for r in _redirects + _live\n", " if r['status'] == 302 and r['location_is_amazonaws']\n", " and r['location_has_signature']]\n", " har_verdict['entries'] = len(_har['log']['entries'])\n", " har_verdict['download_candidates'] = _redirects\n", + " har_verdict['live_redirects'] = _live\n", " har_verdict['presigned_redirects'] = len(_ok)\n", " record('S-1-download-redirect.json',\n", " _redact(json.dumps(har_verdict, indent=2, ensure_ascii=False)))\n", - " assert _ok, (\n", - " 'HAR に「302 かつ Location が X-Amz-Signature 付きの amazonaws.com」'\n", - " 'である応答が無い。決定-19(accept_url で presigned URL に飛ばす)が'\n", - " '効いていない可能性がある。S-1-download-redirect.json を見ること')\n", + " if not _ok:\n", + " # 「G-10 の経路を踏んでいない」と「踏んだのに 302 にならない」は結論が\n", + " # 全く違う。取り違えると PR の欠陥を捏造することになるので言い分ける。\n", + " _nd = [r for r in _redirects + _live if not r.get('has_direct')]\n", + " raise AssertionError(\n", + " 'presigned URL への 302 が 1 件も無い。'\n", + " + ('ダウンロード要求がすべて `direct` 付きで、G-10 の経路を'\n", + " '踏んでいない。ファイル詳細画面の[ダウンロード]は OSF が'\n", + " '`direct` を付ける(addons/base/views.py L984)ので、'\n", + " 'ファイル一覧のツールバーの[ダウンロード]で取り直すこと'\n", + " if not _nd else\n", + " '`direct` の無いダウンロード要求はあるのに 302 になっていない。'\n", + " '決定-19(accept_url で presigned URL に飛ばす)が効いていない')\n", + " + '。S-1-download-redirect.json を見ること')\n", " print(f'S-1 (G-10): presigned URL への 302 を {len(_ok)} 件確認')\n", "else:\n", " record('S-1-download-redirect.json',\n", From 49541b5b30ae4725a9e18b4ed68003ad49c3f106 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 1 Oct 2026 11:32:30 +0900 Subject: [PATCH 14/19] =?UTF-8?q?test(s3):=20S-3=20=E3=81=A7=E3=83=95?= =?UTF-8?q?=E3=82=A9=E3=83=AB=E3=83=80=E3=81=AE=E9=81=85=E5=BB=B6=E8=AA=AD?= =?UTF-8?q?=E3=81=BF=E8=BE=BC=E3=81=BF=E3=81=8C=E8=B5=B0=E3=82=89=E3=81=AA?= =?UTF-8?q?=E3=81=84=E5=95=8F=E9=A1=8C=E3=82=92=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-20260928-153426 の S-3-wb-responses.json が `[]` だった原因。 - フォルダ名のクリックは Treebeard の multiselect で、fangorn の onmultiselect(_fangornMultiselect)は選択するだけ。子の遅延読み込みは toggleFolder 経由でしか走らないため、名前を押しただけでは `?meta=` が 1 本も飛ばない。行頭の tb-toggle-icon を押すようにした(既に開いている = fa-minus のときは押さない) - 展開済みのフォルダをもう一度開いても lazyload は走らない(item.load)ので、 セルを再実行しても同じ条件になるようファイルページを読み直してから始める - 1 本目の一覧要求が返るまで待ち、飛ばないときは行の HTML を添えて落とす。 0 本のままスクロールに入ると「増えない」としか分からない - 行の確認に入る前に #tb-tbody の scrollTop を 0 に戻す。treebeard は表示範囲の 行しか DOM に置かない(仮想スクロール)ので、上のセルのスクロールで底に いるまま始めると 1 件目 "0001" が DOM に無く scroll_into_view_if_needed が 待ち続ける --- ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index 9f77f21..eba852f 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -2270,10 +2270,48 @@ "if run_toomany:\n", " async def _step(page):\n", " global toomany_pages\n", - " wb_responses.clear()\n", " folder = toomany_prefix.rstrip('/')\n", - " await grdm.get_select_folder_title_locator(page, folder).click()\n", + "\n", + " # 展開済みのフォルダをもう一度開いても lazyload は走らない(item.load)。\n", + " # セルを再実行しても同じ条件になるよう、ファイルページを読み直す。\n", + " await page.reload()\n", + " await expect(page.locator('//*[@id = \"treeGrid\"]')).to_be_visible(\n", + " timeout=transition_timeout)\n", + " await expect(grdm.get_select_expanded_storage_title_locator(\n", + " page, addon_storage_name)).to_be_visible(timeout=transition_timeout)\n", " await asyncio.sleep(3)\n", + " wb_responses.clear()\n", + "\n", + " title = grdm.get_select_folder_title_locator(page, folder)\n", + " await expect(title).to_be_visible(timeout=transition_timeout)\n", + " await title.click(timeout=transition_timeout)\n", + "\n", + " # フォルダ名のクリックは Treebeard の multiselect で、fangorn の\n", + " # onmultiselect(_fangornMultiselect)は選択するだけ。子の遅延読み込みは\n", + " # toggleFolder 経由でしか走らないので、行頭の tb-toggle-icon を押す。\n", + " # 名前を押しただけでは ?meta= が 1 本も飛ばない\n", + " # (run-20260928-153426 の S-3-wb-responses.json が [] だったのはこれ)。\n", + " row_xpath = (grdm.get_select_folder_title_xpath(folder)\n", + " + '/ancestor::*[contains(@class, \"tb-row\")][1]')\n", + " toggle_xpath = row_xpath + '//*[contains(@class, \"tb-toggle-icon\")]'\n", + " toggle = page.locator(toggle_xpath).first\n", + " await expect(toggle).to_be_visible(timeout=transition_timeout)\n", + " if await page.locator(\n", + " toggle_xpath + '//i[contains(@class, \"fa-minus\")]').count() == 0:\n", + " await toggle.click(timeout=transition_timeout)\n", + "\n", + " # 1 本目(next_token 無し)が返るまで待つ。0 本のままスクロールに入ると\n", + " # 「増えない」としか分からないので、行の HTML を添えて落とす。\n", + " _deadline = time.time() + transition_timeout / 1000\n", + " while time.time() < _deadline:\n", + " if listing_pages(path_contains=folder):\n", + " break\n", + " await asyncio.sleep(0.5)\n", + " else:\n", + " _row = await page.locator(row_xpath).first.evaluate('e => e.outerHTML')\n", + " raise AssertionError(\n", + " f'{folder} を展開したが一覧要求(?meta=)が飛んでいない。'\n", + " f'WB 応答 {len(wb_responses)} 本 / 行: {_row[:600]}')\n", "\n", " stable = 0\n", " last = -1\n", @@ -2386,6 +2424,12 @@ "if run_toomany and toomany_walk_rows:\n", " async def _step(page):\n", " _t0 = time.time()\n", + " # 上のセルのスクロールで #tb-tbody は底にいる。treebeard は表示範囲の行\n", + " # しか DOM に置かない(仮想スクロール)ので、そのまま始めると 1 件目\n", + " # \"0001\" が DOM に無く scroll_into_view_if_needed が待ち続ける。\n", + " await page.evaluate(\n", + " \"() => { document.querySelector('#tb-tbody').scrollTop = 0; }\")\n", + " await asyncio.sleep(1)\n", " for i, name in enumerate(toomany_names):\n", " try:\n", " await grdm.get_select_file_extension_locator(\n", From fc0211b03e11a53818538387cf816a11c4e89cbf Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 1 Oct 2026 11:32:51 +0900 Subject: [PATCH 15/19] =?UTF-8?q?test(s3):=20S-5=20=E3=81=AE=20copy/move?= =?UTF-8?q?=20=E5=88=A4=E5=AE=9A=E3=82=92=E8=A6=81=E6=B1=82=E6=9C=AC?= =?UTF-8?q?=E6=96=87=E3=81=A8=E8=A1=8C=20notify=20=E3=81=A7=E8=A1=8C?= =?UTF-8?q?=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit これまでの判定は 2 か所とも成立しない見方をしていた。 - 操作の種別: 応答本文の `links.move` を見ていたが、これは成否・種別に関係なく 必ず入っているので copy でも has_move=True になる。action は POST の *要求* 本文にしかないので、セル [9] で拾った request_body から読む。 素のドラッグが copy になっていないか(getCopyMode の altKey 残り)も見る - 成功の知らせ: $osf.growl を待っていたが、成功は treebeard の行 notify で出る (fangorn.js L693)。growl が出るのは失敗したときだけ(L744)なので、 growl を見ていると成功は永遠に拾えない。行 notify を待ち(表示は 1 秒で 消えるので sleep を挟まない)、growl は「出ていないこと」を確かめる側に回す あわせて実機で落ちた箇所を直す。 - ドラッグの前に行を click する。ドラッグの対象は tb.multiselected() なので、 選んでおかないと掴むものが無く POST が 1 本も出ない。POST が 0 本なら落とす - コピーが成功すると fangorn はコピー先を開いて同名の行を出すため、ファイル名 だけの locator が 2 行に当たって strict mode 違反になる(run-20260930-171843)。 行数を控えてからコピー先を畳む。畳まないと次の移動でどちらを掴むか決まらない - 「コピーなので元が残っている」は画面では深さでしか区別できないので、 S3 の head_object で確かめる(移動で消える前に見る) - S-5 の開始時に前回の残骸(ルート / コピー先 / 移動先)を消す。残っていると 「移動したのに元が残っている」の判定が前回の結果に引きずられる --- ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 101 +++++++++++++++--- 1 file changed, 86 insertions(+), 15 deletions(-) diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index eba852f..1cb16df 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -2715,11 +2715,17 @@ "# 移動先・コピー先のフォルダを先に作っておく(空キーを置くと一覧でフォルダになる)\n", "if run_intra:\n", " _c = s3_client(s3_region_default)\n", + " s5_file_name = 'S-5-intra.bin'\n", + " # 前回の S-5 の残骸を消す。残っていると「移動したのに元が残っている」の\n", + " # 判定が前回の結果に引きずられる。\n", + " for _k in (s5_file_name,\n", + " intra_copy_dest + s5_file_name,\n", + " intra_move_dest + s5_file_name):\n", + " _c.delete_object(Bucket=s3_bucket, Key=_k)\n", " for _p in (intra_copy_dest, intra_move_dest):\n", " _c.put_object(Bucket=s3_bucket, Key=_p, Body=b'')\n", " print('作成:', intra_copy_dest, intra_move_dest)\n", "\n", - " s5_file_name = 'S-5-intra.bin'\n", " s5_file_path = os.path.join(work_dir, s5_file_name)\n", " with open(s5_file_path, 'wb') as f:\n", " f.write(os.urandom(intra_file_size))\n", @@ -2782,6 +2788,23 @@ " await page.keyboard.up('Alt')\n", "\n", "\n", + "async def wait_file_op_notified(page, word):\n", + " \"\"\"「移動に成功しました」「コピーに成功しました」を待つ。\n", + "\n", + " 成功の知らせは $osf.growl ではなく treebeard の行 notify で出る\n", + " (fangorn.js L693 `from.notify.update(..., 1000)`)。growl が出るのは\n", + " 失敗したときだけ(L744)なので、growl を見ていると成功は永遠に拾えない。\n", + " 表示は 1 秒で消えるので sleep を挟まずにすぐ待つこと。\n", + " \"\"\"\n", + " try:\n", + " await expect(page.locator(f'//*[contains(text(), \"{word}に成功しました\")]')\n", + " ).to_be_visible(timeout=transition_timeout)\n", + " return True\n", + " except Exception as e:\n", + " print(f'[!] 「{word}に成功しました」を捕まえられなかった: {e!r}')\n", + " return False\n", + "\n", + "\n", "s5_results = {}\n", "\n", "if run_intra:\n", @@ -2789,20 +2812,47 @@ " # --- コピー: Alt + ドラッグ\n", " await clear_growls(page)\n", " wb_responses.clear()\n", + " # ドラッグの対象は tb.multiselected()。先に行を選んでおかないと掴む\n", + " # ものが無く POST が 1 本も出ない(ファイル基本操作 NB のドラッグも\n", + " # 必ず click してから掴んでいる)。\n", + " await grdm.get_select_file_extension_locator(page, s5_file_name).click()\n", " src = grdm.get_select_file_draggable_locator(page, s5_file_name)\n", " dst = grdm.get_select_folder_droppable_locator(page, intra_copy_dest.rstrip('/'))\n", " await drag_and_drop_with_alt(page, src, dst)\n", - " await asyncio.sleep(5)\n", - " s5_results['copy'] = {\n", + " s5_results['copy'] = {'notified': await wait_file_op_notified(page, 'コピー')}\n", + " await asyncio.sleep(3)\n", + " s5_results['copy'].update({\n", " 'growls': await growl_messages(page),\n", " 'wb': [e for e in wb_responses if e['method'] == 'POST'],\n", - " }\n", - " # コピーなので元のファイルは残っている\n", + " })\n", + " assert s5_results['copy']['wb'], (\n", + " 'コピーの POST が 1 本も出ていない(ドラッグが届いていない)。'\n", + " f'growl={s5_results[\"copy\"][\"growls\"]}')\n", + " # コピーが成功すると fangorn はコピー先のフォルダを開いて同名の行を\n", + " # 出す。ファイル名だけの locator はこの 2 行に当たって strict mode\n", + " # 違反になるので、行数を控えたうえでコピー先を畳んでおく。畳まないと\n", + " # 次の移動のドラッグでどちらを掴むかが決まらない。\n", + " s5_results['copy']['rows_after'] = await grdm.get_select_file_title_locator(\n", + " page, s5_file_name).count()\n", + " toggle = grdm.get_select_folder_toggle_locator(\n", + " page, intra_copy_dest.rstrip('/'), expanded=True)\n", + " if await toggle.count() > 0:\n", + " await toggle.first.click(timeout=transition_timeout)\n", + " await asyncio.sleep(1)\n", " await expect(grdm.get_select_file_title_locator(page, s5_file_name)\n", - " ).to_be_visible(timeout=transition_timeout)\n", + " ).to_have_count(1, timeout=transition_timeout)\n", "\n", " await run_pw(_step)\n", - " print(s5_results['copy']['growls'])\n", + " # 「コピーなので元が残っている」は、画面では同名の行が 2 つ出るうえ深さで\n", + " # しか区別できない。S3 の実体で確かめる(移動でこの行が消える前に見る)。\n", + " try:\n", + " _root = s3_client(s3_region_default).head_object(\n", + " Bucket=s3_bucket, Key=s5_file_name)\n", + " except Exception as _e:\n", + " raise AssertionError(f'コピーしたのに元のファイルが消えている: {_e!r}')\n", + " assert _root['ContentLength'] == intra_file_size, \\\n", + " 'コピーの後で元のファイルのサイズが変わっている'\n", + " print(s5_results['copy']['growls'], '同名の行:', s5_results['copy']['rows_after'])\n", "else:\n", " print('run_intra=False のためスキップ')" ], @@ -2821,14 +2871,19 @@ " # --- 移動: 素のドラッグ\n", " await clear_growls(page)\n", " wb_responses.clear()\n", + " await grdm.get_select_file_extension_locator(page, s5_file_name).click()\n", " src = grdm.get_select_file_draggable_locator(page, s5_file_name)\n", " dst = grdm.get_select_folder_droppable_locator(page, intra_move_dest.rstrip('/'))\n", " await grdm.drag_and_drop(page, src, dst)\n", - " await asyncio.sleep(5)\n", - " s5_results['move'] = {\n", + " s5_results['move'] = {'notified': await wait_file_op_notified(page, '移動')}\n", + " await asyncio.sleep(3)\n", + " s5_results['move'].update({\n", " 'growls': await growl_messages(page),\n", " 'wb': [e for e in wb_responses if e['method'] == 'POST'],\n", - " }\n", + " })\n", + " assert s5_results['move']['wb'], (\n", + " '移動の POST が 1 本も出ていない(ドラッグが届いていない)。'\n", + " f'growl={s5_results[\"move\"][\"growls\"]}')\n", "\n", " await run_pw(_step)\n", " print(s5_results['move']['growls'])\n", @@ -2863,19 +2918,29 @@ " }\n", "\n", " def _actions(entries):\n", + " \"\"\"POST の *要求* 本文から action を読む。\n", + "\n", + " 応答本文で判別しようとすると、`links.move` が成否・種別に関係なく\n", + " 必ず入っているため copy でも has_move=True になって意味を成さない。\n", + " \"\"\"\n", " out = []\n", " for e in entries:\n", - " body = e.get('body') or ''\n", + " req = e.get('request_body') or ''\n", + " try:\n", + " action = json.loads(req).get('action')\n", + " except Exception:\n", + " action = None\n", " out.append({'status': e['status'], 'url': e['url'],\n", - " 'has_copy': '\"copy\"' in body or \"'copy'\" in body,\n", - " 'has_move': '\"move\"' in body or \"'move'\" in body})\n", + " 'action': action, 'request_body': req[:300]})\n", " return out\n", "\n", " _verdict = {\n", " 's3': _s3_state,\n", " 'copy': {'growls': s5_results.get('copy', {}).get('growls'),\n", + " 'notified': s5_results.get('copy', {}).get('notified'),\n", " 'posts': _actions(s5_results.get('copy', {}).get('wb', []))},\n", " 'move': {'growls': s5_results.get('move', {}).get('growls'),\n", + " 'notified': s5_results.get('move', {}).get('notified'),\n", " 'posts': _actions(s5_results.get('move', {}).get('wb', []))},\n", " 'expected_file_size': intra_file_size,\n", " }\n", @@ -2888,9 +2953,15 @@ " assert _posts, f'{_op} の POST が観測できていない(ドラッグが届いていない)'\n", " _bad = [p for p in _posts if p['status'] >= 400]\n", " assert not _bad, f'{_op} の POST が失敗している: {_bad}'\n", + " # 素のドラッグが copy になっていないか(getCopyMode の altKey 残り)を見る。\n", + " _acts = [p['action'] for p in _posts]\n", + " assert _op in _acts, \\\n", + " f'{_op} のつもりの操作が action={_acts} で発行されている'\n", + " # growl が出るのは失敗したときだけ(fangorn.js L744)。成功は行 notify。\n", " _growls = _verdict[_op]['growls'] or []\n", - " assert any(f'{_word}に成功しました' in g for g in _growls), \\\n", - " f'{_op} の成功トースト(「{_word}に成功しました」)が出ていない: {_growls}'\n", + " assert not _growls, f'{_op} で失敗トーストが出ている: {_growls}'\n", + " assert _verdict[_op]['notified'], \\\n", + " f'{_op} の成功表示(「{_word}に成功しました」)を捕まえられなかった'\n", "\n", " assert _s3_state['copy_dest']['exists'], 'コピー先に実体が無い'\n", " assert _s3_state['move_dest']['exists'], '移動先に実体が無い'\n", From 70a065e2479bf5cb40f63a1bf555b79e7d65d344 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 1 Oct 2026 14:24:10 +0900 Subject: [PATCH 16/19] =?UTF-8?q?test(s3):=20=E6=A9=9F=E9=96=A2=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=83=AC=E3=83=BC=E3=82=B8=E3=81=AE=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E3=82=92=20submit=20=E3=83=8F=E3=83=B3=E3=83=89=E3=83=A9?= =?UTF-8?q?=E3=81=8C=E4=BB=98=E3=81=84=E3=81=A6=E3=81=8B=E3=82=89=E6=8A=BC?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-20261001-121409 で S-1 が「#s3_modal が hidden のまま」で落ちた原因。 - [保存]の submit ハンドラは rdm-institutional-storage-page.js L78 が バンドル実行時に bind する。goto_institutional_storage が待つのは ラジオボタンの表示だけで、これは HTML の解析時点で満たされるため、 バンドルが遅いとハンドラが付く前に押してしまう。その場合 e.preventDefault() (L103)も効かず素の GET 送信になり、ページが再読込みされるだけで 確認ダイアログも認証モーダルも出ない。保存済みプロバイダが既に s3 だと 再読込み後の画面は押す前と見分けがつかない。jQuery._data で submit ハンドラが付くまで待つ(webpack の externals で require('jquery') は window.jQuery と同一) - 確認ダイアログが出ないケースを except で握りつぶすのをやめ、その場で落とす。 これまでは 20 秒待った末に「モーダルが開かない」としか分からなかった - btn-danger を押した後に「Verification failed」の growl の有無を見る。 照合に失敗すると callback が呼ばれずダイアログだけ閉じる(osfHelpers.js L790)ので、「送信が成立していない」と区別できるようにする。growl は delay=0 で自動では消えない(growlBox.js L20) - bootbox-body の を .first で取り、複数あっても strict mode 違反にしない --- ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index 1cb16df..16dfab7 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -1285,25 +1285,48 @@ " # Save(保存) ボタンをクリック\n", " save_btn = page.locator('//button[@type=\"submit\" and contains(@class, \"btn-success\")]').first\n", " await save_btn.scroll_into_view_if_needed()\n", + " # submit ハンドラは rdm-institutional-storage-page.js (L78) が bind する。\n", + " # ラジオボタンは HTML の解析時点で見えるので、見えた直後に押すとハンドラが\n", + " # まだ無く、素のフォーム送信(GET)でページが再読込みされるだけになる。\n", + " # 確認ダイアログも認証モーダルも出ないまま元の画面に戻る\n", + " # (run-20261001-121409)。\n", + " await page.wait_for_function(\n", + " '() => { var f = document.getElementById(\"institutional_storage_form\");'\n", + " ' if (!f || !window.jQuery) { return false; }'\n", + " ' var ev = window.jQuery._data(f, \"events\");'\n", + " ' return !!(ev && ev.submit && ev.submit.length); }',\n", + " timeout=transition_timeout)\n", " await save_btn.click()\n", - " await asyncio.sleep(2)\n", "\n", " confirm_text = page.locator('#bbConfirmText')\n", " s3_modal = page.locator('#s3_modal')\n", "\n", - " # 確認ダイアログ(bootbox)が出た場合は確認文字列を入力して変更を確定する\n", + " # 確認ダイアログ(bootbox)は osfstorage 以外では必ず出る\n", + " # ($osf.confirmDangerousAction / rdm-institutional-storage-page.js L91)。\n", + " # 出ないのは送信が成立していないということなので、黙って先へ進めない。\n", " try:\n", " await expect(confirm_text).to_be_visible(timeout=10000)\n", - " confirm_strong = page.locator('//div[contains(@class, \"bootbox-body\")]//strong')\n", - " confirmation_string = await confirm_strong.text_content()\n", - " print(f'Confirmation string: {confirmation_string}')\n", - " await confirm_text.fill(confirmation_string)\n", - " await page.locator(\n", - " '//div[contains(@class, \"modal\") and contains(@class, \"bootbox\")]'\n", - " '//button[contains(@class, \"btn-danger\")]').click()\n", - " print('確認ダイアログを処理しました')\n", " except Exception:\n", - " print('確認ダイアログは表示されませんでした')\n", + " raise AssertionError(\n", + " '保存の確認ダイアログが出ない。フォーム送信が成立していない'\n", + " '(submit ハンドラ未 bind、または checkValidity 失敗)。')\n", + " confirm_strong = page.locator('//div[contains(@class, \"bootbox-body\")]//strong')\n", + " confirmation_string = await confirm_strong.first.text_content()\n", + " print(f'Confirmation string: {confirmation_string}')\n", + " await confirm_text.fill(confirmation_string)\n", + " await page.locator(\n", + " '//div[contains(@class, \"modal\") and contains(@class, \"bootbox\")]'\n", + " '//button[contains(@class, \"btn-danger\")]').click()\n", + " await asyncio.sleep(1)\n", + "\n", + " # 確認文字列が合わないと callback は呼ばれず、ダイアログだけ閉じて\n", + " # 「Verification failed」の growl が残る(osfHelpers.js L790)。growl は\n", + " # delay=0 で自動では消えないので、モーダルを 20 秒待つ前にここで判じる。\n", + " verify_failed = page.locator(\n", + " '//*[contains(@class, \"alert\")][contains(., \"Verification failed\")]')\n", + " assert await verify_failed.count() == 0, \\\n", + " f'確認文字列の照合に失敗した: \"{confirmation_string}\"'\n", + " print('確認ダイアログを処理しました')\n", "\n", " # Amazon S3 認証モーダルが開くこと。**開かなければ登録できていない。**\n", " # 元ノートブックは「既設定とみなしてスキップ」していたが、本テストは\n", @@ -3460,6 +3483,7 @@ }, { "cell_type": "code", + "id": "24fb1a07", "metadata": {}, "source": [ "# 生成バケットが消えていることの確認(後始末チェックリストの最後の項目)\n", From c2ddf956f70839ac7fe3c2c4cbfe8f7ab4d0d9df Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 1 Oct 2026 14:45:11 +0900 Subject: [PATCH 17/19] test(s3): finish the SigV4 notebook after the staging2 run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run-20261001-130318 の実行で分かった、証跡と最終確認の表示の誤りを直す。 - 最終確認セル [73]: 後始末で generated_buckets が空になるため、3 つ作って 3 つ消していても cleanup_buckets() が「生成したバケットは無い(すべて パラメータ指定)」と出していた。生成時の名前を generated_bucket_names に 控え、list_buckets の実際と突き合わせて「生成 N / 削除 N / 残存 N」を 出すようにする。BucketAlreadyExists の再試行で作りかけを消したときは 控えからも落とす - INDEX.md の対象 SHA: osf_deployed_sha が未記入のままだと 「RCOSDP/RDM-osf.io#746 (None)」という証跡が残るので、未設定のときは 「未設定(S-0-jenkins-build.txt を参照)」と書く。セル [11] の警告は従来どおり - INDEX.md の取りこぼし: セル [67] は S-1-download-redirect.json(HAR の判定) と S-9-cleanup.txt(後始末)より先に走るため、この 2 つが表に載っていなかった。 生成処理を write_index() に切り出し、後始末セル [71] の末尾で作り直す。 INDEX 自身と finish_pw_context の成果物(har*/video-*/last-*/console.log)は 表に出さない(末尾の注で説明しているもの) --- ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 85 +++++++++++++------ 1 file changed, 61 insertions(+), 24 deletions(-) diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index 16dfab7..5583efc 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -394,6 +394,9 @@ "\n", "# 生成したバケット [(name, region), ...]。後始末はこれだけを対象にする。\n", "generated_buckets = []\n", + "# 生成したバケット名の控え。generated_buckets は後始末で空になるので、\n", + "# 「何を作って何を消したか」を最後に言うにはこちらを見る。\n", + "generated_bucket_names = []\n", "\n", "\n", "def s3_client(region):\n", @@ -545,7 +548,11 @@ " \"\"\"\n", " lines = []\n", " if not generated_buckets:\n", - " lines.append(' 生成したバケットは無い(すべてパラメータ指定)')\n", + " if generated_bucket_names:\n", + " lines.append(f' 生成した {len(generated_bucket_names)} 件は'\n", + " '既にすべて削除済み')\n", + " else:\n", + " lines.append(' 生成したバケットは無い(すべてパラメータ指定)')\n", " elif not delete_generated_buckets:\n", " for name, region in generated_buckets:\n", " lines.append(f' {name} ({region}): delete_generated_buckets=False の'\n", @@ -636,6 +643,7 @@ " _name = _generated_name(_role, bucket_suffix)\n", " _status = _create_bucket(_name, _region)\n", " generated_buckets.append((_name, _region))\n", + " generated_bucket_names.append((_name, _region))\n", " _resolved[_role] = (_name, _region, True)\n", " print(f' {_role}: {_name} ({_region}) -> {_status}')\n", " break\n", @@ -646,6 +654,8 @@ " print('BucketAlreadyExists(他人所有の名前と衝突)。'\n", " 'suffix を変えて 1 回だけ再試行する')\n", " cleanup_buckets(verbose=False)\n", + " # 作りかけは今消したので控えからも落とす(残すと「生成 N」が狂う)\n", + " generated_bucket_names.clear()\n", " bucket_suffix = _new_bucket_suffix()\n", "\n", "assert len({_resolved[_r][0] for _r in _roles}) == len(_roles), \\\n", @@ -3198,30 +3208,47 @@ }, "source": [ "# 証跡の INDEX\n", - "_files = sorted(os.listdir(run_dir))\n", - "_index = ['# 証跡 INDEX',\n", - " '',\n", - " f'- run_id: {run_id}',\n", - " f'- 取得日時: {datetime.now().isoformat()}',\n", - " f'- 対象: RCOSDP/RDM-waterbutler#93 ({wb_deployed_sha}) '\n", - " f'/ RCOSDP/RDM-osf.io#746 ({osf_deployed_sha})',\n", - " '',\n", - " '| ファイル | サイズ | 対応シナリオ |',\n", - " '|---|---|---|']\n", "_scenario = {'S-0': '環境確認 (IF-5/R-5)', 'S-1': '機関ストレージ基本操作 (IF-3既定/G-10)',\n", " 'S-2': '明示リージョン (IF-3)', 'S-3': '1000件超一覧 (P-6/R-6)',\n", " 'S-4': 'バージョン削除 (V-1)', 'S-5': 'intra copy/move (I-2)',\n", " 'S-6': 'CompleteMultipartUpload Error Code (E-1)',\n", " 'S-7': 'チャンクアップロード (E-2/K-4)',\n", " 'S-9': '後始末(バケット削除)'}\n", - "for _f in _files:\n", - " _key = _f[:3]\n", - " _index.append(f'| `{_f}` | {os.path.getsize(os.path.join(run_dir, _f))} | '\n", - " f'{_scenario.get(_key, \"Playwright 成果物\")} |')\n", - "_index.append('')\n", - "_index.append('`har.zip` / `video-*.webm` / `console.log` / `last-*.{png,html}` は '\n", - " '`scripts/playwright.py` が `finish_pw_context` で書き出したもの。')\n", - "record('INDEX.md', '\\n'.join(_index))" + "\n", + "\n", + "def write_index():\n", + " \"\"\"run_dir の中身から INDEX.md を作る。\n", + "\n", + " このセルの時点では S-1-download-redirect.json(HAR の判定)も\n", + " S-9-cleanup.txt(後始末)もまだ無いので、後始末のセルの末尾で\n", + " もう一度呼んで作り直す。\n", + " \"\"\"\n", + " # osf_deployed_sha が未記入だと「対象は None」という証跡が残ってしまう。\n", + " _osf_sha = osf_deployed_sha or '未設定(S-0-jenkins-build.txt を参照)'\n", + " _index = ['# 証跡 INDEX',\n", + " '',\n", + " f'- run_id: {run_id}',\n", + " f'- 取得日時: {datetime.now().isoformat()}',\n", + " f'- 対象: RCOSDP/RDM-waterbutler#93 ({wb_deployed_sha}) '\n", + " f'/ RCOSDP/RDM-osf.io#746 ({_osf_sha})',\n", + " '',\n", + " '| ファイル | サイズ | 対応シナリオ |',\n", + " '|---|---|---|']\n", + " for _f in sorted(os.listdir(run_dir)):\n", + " # INDEX 自身と finish_pw_context の成果物は表に出さない\n", + " # (後者は下の注で説明する)。\n", + " if _f == 'INDEX.md' or _f.startswith(('har', 'video-', 'last-')) \\\n", + " or _f == 'console.log':\n", + " continue\n", + " _index.append(f'| `{_f}` | {os.path.getsize(os.path.join(run_dir, _f))} | '\n", + " f'{_scenario.get(_f[:3], \"Playwright 成果物\")} |')\n", + " _index.append('')\n", + " _index.append('`har.zip` / `video-*.webm` / `console.log` / `last-*.{png,html}` は '\n", + " '`scripts/playwright.py` が `finish_pw_context` で書き出したもの。')\n", + " record('INDEX.md', '\\n'.join(_index))\n", + "\n", + "\n", + "write_index()" ], "execution_count": null, "outputs": [] @@ -3448,9 +3475,10 @@ " + (_cleanup or [' 対象なし'])\n", " + ['', '--- 生成バケットの削除 ---', cleanup_buckets(verbose=True)])\n", "record('S-9-cleanup.txt', _text)\n", - "# INDEX.md はセル [67] で作っているので、S-9-cleanup.txt を載せるには\n", - "# このあとセル [67] をもう一度実行すること。\n", - "print('\\n[!] INDEX.md に S-9-cleanup.txt を載せるにはセル [67] を再実行する')" + "# INDEX.md を作ったセル [67] の時点では S-9-cleanup.txt も\n", + "# S-1-download-redirect.json もまだ無いので、ここで作り直す。\n", + "write_index()\n", + "print('\\nINDEX.md を作り直した(S-9-cleanup.txt まで載る)')" ], "execution_count": null, "outputs": [] @@ -3487,10 +3515,19 @@ "metadata": {}, "source": [ "# 生成バケットが消えていることの確認(後始末チェックリストの最後の項目)\n", + "#\n", + "# generated_buckets は後始末で空になるので、これで数えると作ったバケットが\n", + "# あっても「生成したバケットは無い」と出る。生成時に控えた\n", + "# generated_bucket_names と list_buckets の実際を突き合わせる\n", + "# (S-9-cleanup.txt と list_buckets の結果が正)。\n", "_all = [b['Name'] for b in\n", " s3_client(s3_region_default).list_buckets().get('Buckets', [])]\n", - "_left = [b for b in _all if b.startswith(f'{bucket_prefix}-{bucket_suffix}')]\n", + "_made = [_n for _n, _r in generated_bucket_names]\n", + "_left = [_b for _b in _made if _b in _all]\n", "print(f'list_buckets: {len(_all)} 件')\n", + "print(f'生成 {len(_made)} / 削除 {len(_made) - len(_left)} / 残存 {len(_left)}')\n", + "for _b in _made:\n", + " print(f' - {_b}' + (' **残存**' if _b in _left else ''))\n", "if not delete_generated_buckets:\n", " print('delete_generated_buckets=False。**次のバケットを手で削除すること**:')\n", " for _b in _left:\n", @@ -3499,7 +3536,7 @@ " assert not _left, (\n", " f'生成したバケットが残っている: {_left}。'\n", " 'cleanup_buckets() の出力(S-9-cleanup.txt)を見て手で削除すること')\n", - " print(f'{bucket_prefix}-{bucket_suffix}-* は残っていない')\n" + " print('生成したバケットは残っていない')\n" ], "execution_count": null, "outputs": [] From 18899f25f9aaed6ba5bf4e8ea4035b75415e45e7 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 1 Oct 2026 15:45:11 +0900 Subject: [PATCH 18/19] test(s3): make the SigV4 notebook publishable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 社内のデプロイ基盤名・環境名・内部文書名を取り除き、このリポジトリだけで 読める手順にする。あわせて S-6 スクリプトを単体で回せるようにする。 - S-0 の説明: 特定のデプロイツール/ジョブのビルドログを前提にしていたのを 「試験環境にデプロイされている RDM-waterbutler / RDM-osf.io のコミット SHA が分かる記録(デプロイ時のログ等)」に改める。保存名も S-0-jenkins-build.txt -> S-0-build-log.txt - 環境名を「試験環境」に置き換える。URL は .config.yaml から来るので本文には 出ない - 証跡の既定の置き場をリポジトリ直下の ./evidence/run- に。 別の場所は evidence_dir パラメータで与える。evidence/ を .gitignore に追加 - wb_deployed_sha をハードコードから None 既定のパラメータにし、 osf_deployed_sha と同じく未記入なら警告する。INDEX.md も両方を 「未設定(S-0-build-log.txt を参照)」と書く - scripts/s3_complete_error_codes.py: 資格情報を boto3 の既定の解決順 (環境変数 / AWS_PROFILE / インスタンスロール)で解決するようにし、 --create-bucket で採取用バケットを作って**例外時も**空にして削除する また、*-institutional-storage-after.txt が before と同じ中身になっていたのを 直す。モーダルを閉じただけではページが保存前の描画のままなので、サーバから 読み直してから選択状態と表示名を採り、Amazon S3 になっていることを確かめる。 --- .gitignore | 1 + README.md | 3 +- scripts/s3_complete_error_codes.py | 194 +++++++++++++----- ...2\270-SigV4\345\233\236\345\270\260.ipynb" | 113 +++++----- 4 files changed, 213 insertions(+), 98 deletions(-) diff --git a/.gitignore b/.gitignore index a7c87a3..b02c84a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ venv/ last-screenshots/ test-results/ reports/ +evidence/ # Config files with secrets *.config.yaml diff --git a/README.md b/README.md index 6bdce4e..5f64991 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,8 @@ GRDMには、一般的なGUI操作を行うためのユーティリティ関数 `テスト手順-管理者機能-S3-機関ストレージ-SigV4回帰.ipynb` は、WaterButler の Amazon S3 プロバイダを SigV4 化した際の回帰試験 (S-0〜S-7) を実施するテスト手順 Notebook です。既存の `テスト手順-管理者機能-S3-機関ストレージ.ipynb` とは別ファイルとして管理し、機関ストレージ登録・大量ファイル一覧・バージョニング削除・サーバ側コピー/移動を確認します。 試験に使う S3 バケット 3 つ (通常 / バージョニング有効 / ap-northeast-1) は、**既定では Notebook が実行時に生成し、後始末で削除します**。名前は `{bucket_prefix}-{bucket_suffix}-{std,ver,apne1}` (既定 `e2e-sigv4--<4桁乱数>-…`) で、生成セルが `S-0-buckets.json` に記録します。既存のバケットを使う場合は `.config.yaml` に `s3_bucket` / `s3_bucket_versioned` / `s3_bucket_apne1` の 3 つを書いてください。指定したバケットは本試験が置いた残骸だけを消し、バケット自体は削除しません。`s3:CreateBucket` / `s3:PutBucketVersioning` / `s3:DeleteBucket` を含む IAM 権限が必要です (不足時は生成セルが必要な権限一覧を表示して停止します)。 -`scripts/s3_complete_error_codes.py` は、CompleteMultipartUpload が AWS S3 から返すエラーコードを採取して WaterButler の判定表と突き合わせる単体スクリプトです (Notebook の S-6 に対応)。`--dry-run` で実行計画のみを表示できます。認証情報は環境変数から読み込み、出力には含めません。対象バケットは環境変数 `E2E_S3_BUCKET` で指定しますが、Notebook がバケットを生成する運用では生成セルが出力する `export E2E_S3_BUCKET=…` / `export E2E_S3_REGION=…` の行をそのまま貼って実行します (Notebook の後始末より前に実行すること)。 +`scripts/s3_complete_error_codes.py` は、CompleteMultipartUpload が AWS S3 から返すエラーコードを採取して WaterButler の判定表と突き合わせる単体スクリプトです (Notebook の S-6 に対応)。`--dry-run` で実行計画のみを表示できます。認証情報は boto3 の既定の解決順 (環境変数 / `AWS_PROFILE` / インスタンスロール) で解決し、出力には含めません。 +対象バケットは `--create-bucket` を付けると `e2e-sigv4-s6-` を生成し、**正常終了でも例外でも空にして削除します**。既存のバケットを使う場合は環境変数 `E2E_S3_BUCKET` で指定します。Notebook がバケットを生成する運用では生成セルが出力する `export E2E_S3_BUCKET=…` / `export E2E_S3_REGION=…` の行をそのまま貼って実行します (この場合は Notebook の後始末より前に実行すること)。 ## セキュリティと機密情報の管理 diff --git a/scripts/s3_complete_error_codes.py b/scripts/s3_complete_error_codes.py index 64a2855..1f8e747 100644 --- a/scripts/s3_complete_error_codes.py +++ b/scripts/s3_complete_error_codes.py @@ -7,33 +7,42 @@ 「CompleteMultipartUpload がこのコードで落ちたなら、オブジェクトは確実に できていない(= NOT_COMMITTED)」と判断するための表で、**MinIO の実装と S3 の 仕様書から起こしたもので AWS S3 の実機では確認されていない**(provider.py の -同箇所のコメント)。TEST_SPEC E-1 はこの突合を求めている。 +同箇所のコメント)。受入項目 E-1 はこの突合を求めている。 本スクリプトは AWS に対して壊れた CompleteMultipartUpload を投げ、返ってきた Error Code を採取して表と突き合わせる。**コード側(表)は変更しない。** 表に無いコードが出た場合は、WaterButler の既定どおり ``UNKNOWN`` (= オブジェクトができたかどうか分からない)として記録するだけ。 -これが TEST_SPEC E-1 の合格基準である。 +これが E-1 の合格基準である。 -使い方:: +使い方(バケットも本スクリプトに作らせる場合):: + + AWS_PROFILE=<プロファイル名> \\ + python3 scripts/s3_complete_error_codes.py --create-bucket \\ + --out <証跡ディレクトリ> + +``--create-bucket`` は ``e2e-sigv4-s6-`` を ``E2E_S3_REGION`` +(既定 ``us-east-1``)に作り、**正常終了でも例外でも空にして削除する**。 + +使い方(既にあるバケットを使う場合):: - export AWS_ACCESS_KEY_ID=... - export AWS_SECRET_ACCESS_KEY=... export E2E_S3_BUCKET=... export E2E_S3_REGION=us-east-1 # 省略時 us-east-1 - python3 scripts/s3_complete_error_codes.py --out <証跡ディレクトリ> + AWS_PROFILE=<プロファイル名> \\ + python3 scripts/s3_complete_error_codes.py --out <証跡ディレクトリ> ``E2E_S3_BUCKET`` / ``E2E_S3_REGION`` の 2 行は、SigV4 回帰ノートブックの バケット生成セル(セル [6])が ``export …`` の形で印字するので、それをそのまま 貼ればよい。ノートブックは既定でバケットを生成し**後始末で削除する**ので、 -本スクリプトはノートブックの後始末セルより**前**に実行すること。 -既存バケットを使う運用(``.config.yaml`` に名前を書いた場合)はいつ実行してもよい。 -バケットは本スクリプトが作った multipart を中止するだけで、オブジェクトは残さない。 +その場合はノートブックの後始末セルより**前**に実行すること。 +指定したバケットは本スクリプトが作った multipart を中止するだけで、 +オブジェクトも残さない(バケット自体は消さない)。 -``--dry-run`` を付けると AWS には一切アクセスせず、呼び出し計画だけを印字する。 +資格情報は boto3 の既定の解決順(環境変数 / ``AWS_PROFILE`` / +インスタンスロール)で解決する。コマンドライン引数では受け取らない +(ps に出るため)。出力の直前にアクセスキーが混ざっていないことを検査する。 -資格情報はコマンドライン引数では受け取らない(ps に出るため)。出力の直前に -アクセスキーが混ざっていないことを検査する。 +``--dry-run`` を付けると AWS には一切アクセスせず、呼び出し計画だけを印字する。 """ import argparse import json @@ -195,12 +204,24 @@ def assert_no_credentials(text, access_key=None, secret_key=None): def print_plan(args, bucket, region, deny_bucket): print('S-6 CompleteMultipartUpload Error Code 採取 — 呼び出し計画 (--dry-run)') print() - print(f' bucket : {bucket or ""}') + if args.create_bucket: + print(' bucket : e2e-sigv4-s6-' + '(--create-bucket で生成し、終了時に空にして削除する)') + else: + print(f' bucket : {bucket or ""}') print(f' region : {region}') + print(' credentials : boto3 の既定の解決順' + '(環境変数 / AWS_PROFILE / インスタンスロール)') print(f' key prefix : {args.prefix}') print(f' out : {args.out}') print(f' deny bucket : {deny_bucket or "(未設定 → access-denied はスキップ)"}') print() + if args.create_bucket: + print(' [create-bucket] 採取用バケットを作る') + print(' - create_bucket(Bucket=e2e-sigv4-s6-)') + print(' # 終了時(例外が出た場合も)に multipart を中止し、' + 'オブジェクトを消して delete_bucket する') + print() for name, desc, calls in CASES: if name == 'access-denied' and not args.with_access_denied: print(f' [skip] {name}: --with-access-denied が無いためスキップ') @@ -224,8 +245,9 @@ def print_plan(args, bucket, region, deny_bucket): # --------------------------------------------------------------------------- -def _client(region, access_key=None, secret_key=None): - """S3 クライアント。資格情報を渡さなければ通常の解決順(環境変数など)。 +def _client(region, access_key=None, secret_key=None, session_token=None): + """S3 クライアント。資格情報を渡さなければ boto3 の既定の解決順 + (環境変数 / AWS_PROFILE / インスタンスロール)に任せる。 access_key / secret_key を明示すると、その資格情報だけを使う (ケース 8・9 用。壊した資格情報で complete を投げるため)。 @@ -239,10 +261,69 @@ def _client(region, access_key=None, secret_key=None): if access_key is not None: kwargs['aws_access_key_id'] = access_key kwargs['aws_secret_access_key'] = secret_key - kwargs['aws_session_token'] = None + kwargs['aws_session_token'] = session_token return boto3.client('s3', **kwargs) +def _resolve_credentials(): + """既定の解決順で資格情報を解決し、(access_key, secret_key, token) を返す。 + + 解決した値は「壊した署名を作る」(ケース 9)のと、出力に混ざっていない + ことを検査するのにしか使わない。**印字も証跡への書き出しもしない。** + 解決できなければ 3 つとも None。 + """ + import boto3 + creds = boto3.Session().get_credentials() + if creds is None: + return None, None, None + frozen = creds.get_frozen_credentials() + return frozen.access_key, frozen.secret_key, frozen.token + + +def _make_bucket(client, bucket, region): + """採取用のバケットを作る。""" + kwargs = {'Bucket': bucket} + if region != 'us-east-1': + # us-east-1 以外は LocationConstraint が要る(us-east-1 は付けると弾かれる) + kwargs['CreateBucketConfiguration'] = {'LocationConstraint': region} + client.create_bucket(**kwargs) + + +def _drop_bucket(client, bucket): + """生成したバケットを空にして削除する。**残骸を残さない。** + + 採取の途中で例外が出ても呼ばれる(main の finally)。削除に失敗したら + 黙らずに、手で消すべきバケット名を標準エラーに出す。 + """ + counts = {'aborted_uploads': 0, 'deleted_versions': 0, 'deleted_objects': 0} + try: + for page in client.get_paginator( + 'list_multipart_uploads').paginate(Bucket=bucket): + for u in page.get('Uploads', []): + client.abort_multipart_upload( + Bucket=bucket, Key=u['Key'], UploadId=u['UploadId']) + counts['aborted_uploads'] += 1 + for page in client.get_paginator( + 'list_object_versions').paginate(Bucket=bucket): + for kind in ('Versions', 'DeleteMarkers'): + for o in page.get(kind, []): + client.delete_object(Bucket=bucket, Key=o['Key'], + VersionId=o['VersionId']) + counts['deleted_versions'] += 1 + for page in client.get_paginator('list_objects_v2').paginate(Bucket=bucket): + for o in page.get('Contents', []): + client.delete_object(Bucket=bucket, Key=o['Key']) + counts['deleted_objects'] += 1 + client.delete_bucket(Bucket=bucket) + except Exception as e: + print(f'[!] 生成バケットを削除できなかった: {bucket} ' + f'({type(e).__name__}: {e})', file=sys.stderr) + print(' **手で削除すること。**', file=sys.stderr) + return False + print(f'生成バケットを削除した: {bucket} {counts}') + return True + + def _break_secret(secret): """シークレットの末尾 1 文字を別の文字に置き換える。 @@ -319,7 +400,8 @@ def _cleanup(client, bucket, key, upload_id, obs): def run_cases(client, bucket, deny_bucket, prefix, with_access_denied, - region='us-east-1', access_key=None, secret_key=None): + region='us-east-1', access_key=None, secret_key=None, + session_token=None): body_5m = b'0' * MIN_PART_SIZE body_1m = b'0' * (1024 * 1024) results = [] @@ -457,7 +539,8 @@ def key_for(name): up = client.create_multipart_upload(Bucket=bucket, Key=key)['UploadId'] etag = client.upload_part(Bucket=bucket, Key=key, UploadId=up, PartNumber=1, Body=body_5m)['ETag'] - bad_sig_client = _client(region, access_key, _break_secret(secret_key)) + bad_sig_client = _client(region, access_key, _break_secret(secret_key), + session_token) obs = _observe(lambda: bad_sig_client.complete_multipart_upload( Bucket=bucket, Key=key, UploadId=up, MultipartUpload={'Parts': [{'PartNumber': 1, 'ETag': etag}]})) @@ -514,7 +597,7 @@ def build_report(results, bucket, region, started_at, skipped): 'codes_not_in_table': unlisted, 'note': ('コード側(DEFINITIVE_REJECTION_CODES)は変更していない。' '表に無いコードは WaterButler の既定どおり UNKNOWN として扱う' - '(TEST_SPEC E-1 の合格基準)。'), + '(E-1 の合格基準)。'), } return report @@ -601,22 +684,29 @@ def main(argv=None): parser.add_argument('--with-access-denied', action='store_true', help='access-denied ケースを実施する' '(E2E_S3_DENY_BUCKET が必要)') + parser.add_argument('--create-bucket', action='store_true', + help='採取用のバケットを作り、終了時に空にして削除する' + '(E2E_S3_BUCKET は不要)') args = parser.parse_args(argv) bucket = os.environ.get('E2E_S3_BUCKET') region = os.environ.get('E2E_S3_REGION', 'us-east-1') deny_bucket = os.environ.get('E2E_S3_DENY_BUCKET') - access_key = os.environ.get('AWS_ACCESS_KEY_ID') - secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY') if args.dry_run: return print_plan(args, bucket, region, deny_bucket) - missing = [n for n, v in (('AWS_ACCESS_KEY_ID', access_key), - ('AWS_SECRET_ACCESS_KEY', secret_key), - ('E2E_S3_BUCKET', bucket)) if not v] - if missing: - print('環境変数が足りない: ' + ', '.join(missing), file=sys.stderr) + if not bucket and not args.create_bucket: + print('バケットが決まらない。E2E_S3_BUCKET を設定するか ' + '--create-bucket を付けること。', file=sys.stderr) + return 2 + + # 資格情報は boto3 の既定の解決順に任せる(AWS_PROFILE でもよい)。 + access_key, secret_key, session_token = _resolve_credentials() + if access_key is None: + print('資格情報が解決できない。AWS_PROFILE か ' + 'AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY を設定すること。', + file=sys.stderr) print('(--dry-run なら資格情報なしで呼び出し計画だけ見られる)', file=sys.stderr) return 2 @@ -626,26 +716,38 @@ def main(argv=None): elif not deny_bucket: skipped['access-denied'] = 'E2E_S3_DENY_BUCKET が未設定' - started_at = datetime.now().isoformat() client = _client(region) - results = run_cases(client, bucket, deny_bucket, args.prefix, - args.with_access_denied, - region=region, access_key=access_key, - secret_key=secret_key) - - report = build_report(results, bucket, region, started_at, skipped) - text_json = redact(json.dumps(report, indent=2, ensure_ascii=False)) - text_md = redact(build_markdown(report)) - assert_no_credentials(text_json, access_key, secret_key) - assert_no_credentials(text_md, access_key, secret_key) - - os.makedirs(args.out, exist_ok=True) - path_json = os.path.join(args.out, OUT_JSON) - path_md = os.path.join(args.out, OUT_MD) - with open(path_json, 'w', encoding='utf-8') as f: - f.write(text_json + '\n') - with open(path_md, 'w', encoding='utf-8') as f: - f.write(text_md + '\n') + created_bucket = None + if args.create_bucket: + bucket = 'e2e-sigv4-s6-' + datetime.now().strftime('%Y%m%d-%H%M%S') + _make_bucket(client, bucket, region) + created_bucket = bucket + print(f'採取用バケットを生成した: {bucket} ({region})') + + started_at = datetime.now().isoformat() + try: + results = run_cases(client, bucket, deny_bucket, args.prefix, + args.with_access_denied, + region=region, access_key=access_key, + secret_key=secret_key, session_token=session_token) + + report = build_report(results, bucket, region, started_at, skipped) + text_json = redact(json.dumps(report, indent=2, ensure_ascii=False)) + text_md = redact(build_markdown(report)) + assert_no_credentials(text_json, access_key, secret_key) + assert_no_credentials(text_md, access_key, secret_key) + + os.makedirs(args.out, exist_ok=True) + path_json = os.path.join(args.out, OUT_JSON) + path_md = os.path.join(args.out, OUT_MD) + with open(path_json, 'w', encoding='utf-8') as f: + f.write(text_json + '\n') + with open(path_md, 'w', encoding='utf-8') as f: + f.write(text_md + '\n') + finally: + # 採取が途中で落ちても生成バケットは残さない。 + if created_bucket: + _drop_bucket(client, created_bucket) print(text_md) print() @@ -656,7 +758,7 @@ def main(argv=None): print('[!] 表に無いコードを採取した: ' + ', '.join(report['codes_not_in_table'])) print(' コードは変更せず、UNKNOWN 既定のまま記録した' - '(TEST_SPEC E-1 の合格基準)。') + '(E-1 の合格基準)。') return 0 diff --git "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" index 5583efc..154f02c 100644 --- "a/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" +++ "b/\343\203\206\343\202\271\343\203\210\346\211\213\351\240\206-\347\256\241\347\220\206\350\200\205\346\251\237\350\203\275-S3-\346\251\237\351\226\242\343\202\271\343\203\210\343\203\254\343\203\274\343\202\270-SigV4\345\233\236\345\270\260.ipynb" @@ -83,7 +83,7 @@ "run_toomany = True\n", "run_versioned = True\n", "run_intra = True\n", - "# S-7 チャンク(マルチパート)アップロード。E2E_PLAN v0.2 では任意\n", + "# S-7 チャンク(マルチパート)アップロード。任意\n", "run_chunked = True\n", "# S-4 の X9 実機版(同一 Key に 1001 版)。既定は無効\n", "run_versioned_bulk = False\n", @@ -97,12 +97,12 @@ "# 超えないとマルチパートにならない。既定 200,000,000 = 64,000,000 × 3 + 8,000,000\n", "# の 4 パート。セル [62] の説明を参照。\n", "chunked_file_size = 200000000\n", - "# WB providers/s3/settings.py の既定値。staging2 で env により変えられていたら\n", + "# WB providers/s3/settings.py の既定値。試験環境で env により変えられていたら\n", "# ここも合わせる(判定は警告どまりにしてある)。\n", "wb_chunk_size = 64000000\n", "wb_contiguous_upload_size_limit = 128000000\n", "\n", - "# 証跡の保存先。既定は ../aws-s3-sigv4-improvement/evidence/e2e/run-\n", + "# 証跡の保存先。既定は ./evidence/run-(リポジトリ直下)\n", "evidence_dir = None\n", "\n", "# プロジェクト名プレフィックス\n", @@ -206,11 +206,10 @@ "\n", "work_dir = tempfile.mkdtemp()\n", "\n", - "# 証跡の保存先。E2E_PLAN §0 の指定どおり aws-s3-sigv4-improvement/evidence/e2e/ 配下。\n", + "# 証跡の保存先。既定はリポジトリ直下の evidence/(.gitignore 済み)。\n", + "# 別の場所に置きたいときは evidence_dir パラメータで与える。\n", "if evidence_dir is None:\n", - " evidence_dir = os.environ.get(\n", - " 'E2E_EVIDENCE_DIR',\n", - " os.path.join(os.pardir, 'aws-s3-sigv4-improvement', 'evidence', 'e2e'))\n", + " evidence_dir = os.environ.get('E2E_EVIDENCE_DIR', 'evidence')\n", "run_id = datetime.now().strftime('%Y%m%d-%H%M%S')\n", "run_dir = os.path.abspath(os.path.join(evidence_dir, f'run-{run_id}'))\n", "os.makedirs(run_dir, exist_ok=True)\n", @@ -224,7 +223,7 @@ "\n", "\n", "def evidence(name):\n", - " \"\"\"証跡ファイルのパスを作る。名前には S-番号を含めること(E2E_PLAN §2)。\"\"\"\n", + " \"\"\"証跡ファイルのパスを作る。名前には S-番号を含めること。\"\"\"\n", " return os.path.join(run_dir, name)\n", "\n", "\n", @@ -262,8 +261,8 @@ "- シナリオ名: AWS S3 を機関ストレージとアドオンの両方で使い、SigV4 署名・\n", " presigned URL リダイレクト・1000 件超の一覧・バージョン削除・intra copy/move\n", " が壊れていないことを確かめる\n", - "- **対象 PR: RDM-waterbutler#93(`feature/s3-sigv4`, head `cf2f87ab`)/ RDM-osf.io#746**\n", - "- 取る証跡: TEST_SPEC v1.8 の **P-6 / V-1 / I-2 / IF-3 / G-10** の実機証跡\n", + "- **対象 PR: RDM-waterbutler#93(`feature/s3-sigv4`)/ RDM-osf.io#746**\n", + "- 取る証跡: 受入項目 **P-6 / V-1 / I-2 / IF-3 / G-10** の実機証跡\n", "- 用意するテストデータ: URL 一覧、アカウント(既存ユーザー1: GRDM)、管理者アカウント、\n", " AWS S3 のアクセスキー/シークレットキー。**3 つのバケット(通常 / バージョニング有効 /\n", " `ap-northeast-1`)はノートブックが生成し、後始末で削除する**(セル [6])\n", @@ -272,7 +271,7 @@ "\n", "| シナリオ | 受入項目 | 使うモード | 証跡 |\n", "|---|---|---|---|\n", - "| S-0 環境確認 | IF-5 / R-5 | — | `S-0-jenkins-build.txt`(手動) / `S-0-params.txt` |\n", + "| S-0 環境確認 | IF-5 / R-5 | — | `S-0-build-log.txt`(手動) / `S-0-params.txt` |\n", "| S-1 機関ストレージ登録・アップロード・ダウンロード | IF-3 既定 / **G-10** | 機関ストレージ | `S-1-wb-responses.json` / `S-1-download-redirect.json` |\n", "| S-2 明示リージョン(`ap-northeast-1`) | **IF-3** 明示 | 機関ストレージ | `S-2-wb-responses.json` |\n", "| S-3 1000 件超フォルダ | **P-6** / R-6 | **S3 アドオン** | `S-3-listing-pages.json` |\n", @@ -311,10 +310,8 @@ "\n", "したがって **P-6(一覧のページング)・V-1(バージョン削除)・I-2(intra copy/move)は\n", "機関ストレージ経由では 1 行も実行されない**。これらを実機で踏むには\n", - "`s3` アドオンをプロジェクトに接続するしかない。E2E_PLAN v0.2 §1 は\n", - "「外部ストレージ(アドオン)モードでの網羅は別途」としているが、**網羅ではなく\n", - "当該 3 項目を実行できる唯一の経路**としてアドオンを使う。\n", - "(この判断は PHASE_E2E_PREP_REPORT.md にも記載した)\n", + "`s3` アドオンをプロジェクトに接続するしかない。**アドオンモードの網羅が\n", + "目的ではなく、当該 3 項目を実行できる唯一の経路**としてアドオンを使う。\n", "\n", "## リージョンの決まり方(IF-3 の検証方法)\n", "\n", @@ -330,9 +327,10 @@ "\n", "## 実行前に用意しておくこと\n", "\n", - "1. staging2 の **RDM-waterbutler が `cf2f87ab`(PR #93 head)**、\n", + "1. 試験環境の **RDM-waterbutler が PR #93 の head**、\n", " **RDM-osf.io が PR #746 の head** に差し替わっていること。\n", - " Jenkins `r-deploy` のビルドログを `S-0-jenkins-build.txt` に手で保存する(S-0)。\n", + " デプロイ時のログなど両者のコミット SHA が分かる記録を、証跡ディレクトリに\n", + " `S-0-build-log.txt` として保存する(S-0)。\n", "2. `.config.yaml`(リポジトリ直下、.gitignore 済み)に資格情報を書いておくか、\n", " 実行時のプロンプトに入力する。**ノートブックにキーを書かない**。\n", "3. バケットは**用意しなくてよい**。セル [6] が次の 3 つを生成し、後始末で\n", @@ -860,8 +858,8 @@ " \"\"\"ファイルツリーの機関ストレージノードの表示名を読む。\n", "\n", " 表示名は管理画面の #storage_name の値で決まるので環境ごとに違う。S-1 の\n", - " 登録は既に値が入っていれば上書きしない(セル [18])ため、staging2 では\n", - " 既存の 'E2E Quota MinIO (S3CompatSigV4)' がそのまま残っていた。\n", + " 登録は既に値が入っていれば上書きしない(セル [18])ため、以前に別の\n", + " ストレージで登録した表示名がそのまま残っていることがある。\n", " パラメータの名前で探すと環境依存で落ちるので、ノードのアイコン\n", " (osfstorage の comicon_custom_storage.png)で特定して実名を読む。\n", " \"\"\"\n", @@ -941,27 +939,20 @@ "source": [ "## S-0: 環境確認(IF-5 / R-5)\n", "\n", - "### 手で保存するもの — `S-0-jenkins-build.txt`\n", + "### 手で保存するもの — `S-0-build-log.txt`\n", "\n", - "Jenkins の `r-deploy` ジョブのビルドログから、次の行をコピーして\n", - "証跡ディレクトリに `S-0-jenkins-build.txt` として保存する。\n", - "**ビルドログを取らずに「差し替えたはず」で進めないこと。**\n", - "このジョブは WB だけでなく osf.io / CAS / MFR など 8 リポジトリを同時に\n", - "再ビルドするため、直前のビルドが誰の何だったかは追跡が要る。\n", + "試験環境にデプロイされている RDM-waterbutler / RDM-osf.io のコミット SHA が\n", + "分かる記録(デプロイ時のログ等)を、証跡ディレクトリに\n", + "`S-0-build-log.txt` として保存する。\n", + "**記録を取らずに「差し替えたはず」で進めないこと。**\n", "\n", - "1. stage `build RDM-waterbutler` の\n", - " `Checking out Revision cf2f87ab...`\n", - " — PR RCOSDP/RDM-waterbutler#93 の head をビルドしたことの証明\n", - "2. stage `build RDM-osf.io` の\n", - " `Checking out Revision <#746 の head SHA>`\n", - " — PR RCOSDP/RDM-osf.io#746 の head をビルドしたことの証明。\n", - " SHA は実施者が PR 画面で確認して記録する\n", - "3. デプロイ段の\n", - " `helm upgrade -i rdm-wb ... --set image.tag=` と\n", - " `helm upgrade -i rdm-osf ... --set image.tag=`\n", - " — そのビルドが実際に staging2 に載ったことの証明\n", + "次の 2 つが読み取れることを確かめる。\n", "\n", - "`wb_deployed_sha` / `osf_deployed_sha` に手で控えて、次のセルで証跡に残す。\n", + "1. RDM-waterbutler が PR RCOSDP/RDM-waterbutler#93 の head であること\n", + "2. RDM-osf.io が PR RCOSDP/RDM-osf.io#746 の head であること\n", + "\n", + "どちらの SHA も実施者が PR 画面と突き合わせて確認し、`wb_deployed_sha` /\n", + "`osf_deployed_sha` に手で控えて、次のセルで証跡に残す。\n", "\n", "バケットの前提(所在・バージョニング・空であること)は**セル [6]** が\n", "`S-0-buckets.json` に書いている。ここでは重複して採らない。" @@ -975,8 +966,10 @@ }, "source": [ "# S-0: 実行時パラメータの記録。**キーは書かない**。\n", - "wb_deployed_sha = 'cf2f87ab' # PR RCOSDP/RDM-waterbutler#93 head\n", - "osf_deployed_sha = None # PR RCOSDP/RDM-osf.io#746 head。実施者が記入する\n", + "# 試験環境にデプロイされている SHA。S-0-build-log.txt と突き合わせて\n", + "# 実施者が記入する。\n", + "wb_deployed_sha = None # PR RCOSDP/RDM-waterbutler#93 head\n", + "osf_deployed_sha = None # PR RCOSDP/RDM-osf.io#746 head\n", "\n", "s0 = {\n", " 'run_id': run_id,\n", @@ -1012,8 +1005,12 @@ "assert s3_access_key not in _text and s3_secret_key not in _text, \\\n", " 'S-0-params.txt に資格情報が混ざっている'\n", "record('S-0-params.txt', _text)\n", - "if osf_deployed_sha is None:\n", - " print('\\n[!] osf_deployed_sha が未記入。PR #746 の head SHA を上のセルに記入して再実行すること')" + "for _name, _sha, _pr in [\n", + " ('wb_deployed_sha', wb_deployed_sha, 'RCOSDP/RDM-waterbutler#93'),\n", + " ('osf_deployed_sha', osf_deployed_sha, 'RCOSDP/RDM-osf.io#746')]:\n", + " if _sha is None:\n", + " print(f'\\n[!] {_name} が未記入。{_pr} の head SHA を上のセルに'\n", + " '記入して再実行すること')" ], "execution_count": null, "outputs": [] @@ -1369,7 +1366,19 @@ " await save_modal_btn.click()\n", " await expect(s3_modal).to_be_hidden(timeout=transition_timeout)\n", " await asyncio.sleep(2)\n", - " await capture_institutional_storage_state(page, f'{label}-institutional-storage-after')\n", + "\n", + " # モーダルを閉じただけでは、ページは保存前に描画されたままである。\n", + " # そこで読むと *-institutional-storage-after.txt が\n", + " # S-0-institutional-storage-before.txt と同じ中身になり、\n", + " # 「何に変わったか」の証跡にならない(run-20261001-130318)。\n", + " # サーバから読み直してから選択状態と表示名を採る。\n", + " await goto_institutional_storage(page)\n", + " await select_institution(page)\n", + " state = await capture_institutional_storage_state(\n", + " page, f'{label}-institutional-storage-after')\n", + " assert state['selected'] == 's3', (\n", + " f'保存後に選択されているのが Amazon S3 ではない: {state[\"selected\"]}。'\n", + " '機関ストレージが切り替わっていない')\n", " print(f'{label}: {bucket} を機関ストレージとして登録した')" ], "execution_count": null, @@ -3017,7 +3026,7 @@ "source": [ "## (任意) S-7: チャンク(マルチパート)アップロード(E-2 / K-4)\n", "\n", - "E2E_PLAN v0.2 の S-7。実 AWS に大きめのファイルを上げて**正常完了**することだけを見る。\n", + "S-7。実 AWS に大きめのファイルを上げて**正常完了**することだけを見る。\n", "`CompleteMultipartUpload` の**失敗系**は UI からは起こせないので S-6(boto3 スクリプト)と\n", "単体テストで担保する — ここは「成功経路が SigV4 で通る」ことの証跡。\n", "\n", @@ -3059,7 +3068,7 @@ "2. `N == ceil(chunked_file_size / CHUNK_SIZE)`(既定では **4**)— **警告どまり**\n", "\n", "2 を警告にとどめるのは、`CONTIGUOUS_UPLOAD_SIZE_LIMIT` も `CHUNK_SIZE` も\n", - "`S3_PROVIDER_CONFIG` の環境変数で上書きできるため。staging2 側で変えられていれば\n", + "`S3_PROVIDER_CONFIG` の環境変数で上書きできるため。試験環境側で変えられていれば\n", "パート数は変わる。その場合は `wb_chunk_size` / `wb_contiguous_upload_size_limit`\n", "(セル [0])を実際の値に合わせる。\n", "\n", @@ -3168,7 +3177,7 @@ " # 1) マルチパートを通ったこと自体は必須(E-2 の経路)\n", " assert _verdict['multipart'], (\n", " f'ETag にパート数の接尾辞が無い({_etag})。単発 PUT で上がっており、'\n", - " 'CompleteMultipartUpload を通っていない。staging2 の '\n", + " 'CompleteMultipartUpload を通っていない。試験環境の '\n", " 'S3_PROVIDER_CONFIG.CONTIGUOUS_UPLOAD_SIZE_LIMIT を確認し、'\n", " 'chunked_file_size をそれ以上にして再実行する')\n", "\n", @@ -3176,7 +3185,7 @@ " if _verdict['part_count'] != s7_expected_parts:\n", " print(f'警告: パート数が {_verdict[\"part_count\"]}、期待は '\n", " f'{s7_expected_parts}(CHUNK_SIZE={wb_chunk_size} 前提)。'\n", - " 'staging2 の S3_PROVIDER_CONFIG.CHUNK_SIZE が既定と違う可能性がある。'\n", + " '試験環境の S3_PROVIDER_CONFIG.CHUNK_SIZE が既定と違う可能性がある。'\n", " 'マルチパート経路自体は通っているので E-2 としては合格。')\n", " print('S-7:', _verdict['ContentLength'], 'bytes, ETag =', _etag,\n", " f'(パート数 {_verdict[\"part_count\"]} / 期待 {s7_expected_parts})')\n", @@ -3223,13 +3232,15 @@ " S-9-cleanup.txt(後始末)もまだ無いので、後始末のセルの末尾で\n", " もう一度呼んで作り直す。\n", " \"\"\"\n", - " # osf_deployed_sha が未記入だと「対象は None」という証跡が残ってしまう。\n", - " _osf_sha = osf_deployed_sha or '未設定(S-0-jenkins-build.txt を参照)'\n", + " # 未記入だと「対象は None」という証跡が残ってしまう。\n", + " _unset = '未設定(S-0-build-log.txt を参照)'\n", + " _wb_sha = wb_deployed_sha or _unset\n", + " _osf_sha = osf_deployed_sha or _unset\n", " _index = ['# 証跡 INDEX',\n", " '',\n", " f'- run_id: {run_id}',\n", " f'- 取得日時: {datetime.now().isoformat()}',\n", - " f'- 対象: RCOSDP/RDM-waterbutler#93 ({wb_deployed_sha}) '\n", + " f'- 対象: RCOSDP/RDM-waterbutler#93 ({_wb_sha}) '\n", " f'/ RCOSDP/RDM-osf.io#746 ({_osf_sha})',\n", " '',\n", " '| ファイル | サイズ | 対応シナリオ |',\n", @@ -3503,8 +3514,8 @@ "- [ ] バケットが空になっていることを確認する(上のセルの `S-9-cleanup.txt`)\n", "- [ ] **セル [6] が生成した 3 バケットが消えている**ことを確認する\n", " (次のセル。`list_buckets` に `{bucket_prefix}-{bucket_suffix}-*` が無いこと)\n", - "- [ ] `S-0-jenkins-build.txt` を手で保存したか確認する\n", - "- [ ] 証跡ディレクトリを `aws-s3-sigv4-improvement/evidence/e2e/` 配下に置いたか確認する\n", + "- [ ] `S-0-build-log.txt` を手で保存したか確認する\n", + "- [ ] 証跡ディレクトリ(`run_dir`)に S-0〜S-9 の証跡が揃っているか確認する\n", "- [ ] S-6(`scripts/s3_complete_error_codes.py`)を実行し、\n", " `S-6-complete-error-codes.json` を同じ証跡ディレクトリに入れる" ] From 8af4e13c0468419466186b7b95ccbbf70cf0e463 Mon Sep 17 00:00:00 2001 From: tishin-endou Date: Thu, 1 Oct 2026 16:41:57 +0900 Subject: [PATCH 19/19] test(s3): add a cleanup script for the SigV4 regression run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ノートブックの「後始末チェックリスト」のうち画面操作が要る 2 つ (本ノートブックが作ったプロジェクトの削除、ユーザー設定 → 「アドオンアカウント構成」からの Amazon S3 連携解除)を単体で回せるようにする。 手でやると消し忘れ・消し違いが起きるうえ、何を消したかの証跡が残らない。 - 接続先と資格情報は .config.yaml から読む(セル [1] と同じ)。**値は印字しない** - --dry-run で対象の有無と画面だけを採る。想定と違うものが入っていないか 先に確かめてから本実行する - 削除前後のプロジェクト一覧と、連携解除の前後を S-9-*.png として残す。 削除後に対象が残っていたら assert で落とす(「消した」と書いて残さない) - **機関ストレージには触らない。** 元の設定に戻したあとに触ると壊すため - session 証跡(video-N.webm / har.zip / console.log / last-*)は固定名なので、 証跡ディレクトリ直下ではなく cleanup/ に分けて書く。直下にすると本採取が 残した同名ファイルを上書きする - scripts/ 直下から起動すると sys.path[0] が scripts/ になり scripts/playwright.py が本物の playwright パッケージを隠すので、 自分のディレクトリを sys.path から外す - login は about:blank のまま呼ぶと IdP 選択に届かない。セル [24] と同じ順で rdm_url を開き、同意ボタンを捌いてから呼ぶ --- scripts/cleanup_sigv4_run.py | 273 +++++++++++++++++++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 scripts/cleanup_sigv4_run.py diff --git a/scripts/cleanup_sigv4_run.py b/scripts/cleanup_sigv4_run.py new file mode 100644 index 0000000..4181200 --- /dev/null +++ b/scripts/cleanup_sigv4_run.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +"""SigV4 回帰の証跡取りで作ったものを試験環境から片付ける。 + +ノートブック(テスト手順-管理者機能-S3-機関ストレージ-SigV4回帰.ipynb)の +「後始末チェックリスト」のうち、画面操作が要る 2 つを行う。 + +1. 本ノートブックが作ったプロジェクトを削除する + (`-institutional` / `-apne1` / `-addon` / `-versioned`) +2. ユーザー設定 →「アドオンアカウント構成」から Amazon S3 の連携を解除する + +**機関ストレージには触らない。** 既に元の設定に戻してあるため、ここで触ると +戻した設定を壊す。 + +接続先と資格情報は .config.yaml から読む(ノートブックのセル [1] と同じ)。 +リポジトリ直下で実行すること。 + + # まず何を消すのか確認する(削除も解除もしない。画面だけ撮る) + python3 scripts/cleanup_sigv4_run.py --prefix TEST-S3-SIGV4- --dry-run + + # 実行する + python3 scripts/cleanup_sigv4_run.py --prefix TEST-S3-SIGV4- \ + --out <証跡ディレクトリ> +""" +import argparse +import asyncio +import os +import sys + +import yaml + +_HERE = os.path.dirname(os.path.abspath(__file__)) +# scripts/ 直下のスクリプトとして起動すると sys.path[0] が scripts/ になり、 +# scripts/playwright.py が本物の playwright パッケージを隠す +# (ModuleNotFoundError: 'playwright' is not a package)。 +# scripts/ を外し、代わりにリポジトリ直下を入れる。 +sys.path[:] = [p for p in sys.path if os.path.abspath(p or '.') != _HERE] +sys.path.insert(0, os.path.dirname(_HERE)) + +from scripts.playwright import ( # noqa: E402 + expect, finish_pw_context, init_pw_context, run_pw, save_screenshot) +from scripts import grdm # noqa: E402 + +SUFFIXES = ['institutional', 'apne1', 'addon', 'versioned'] + +# ユーザー設定 → アドオンアカウント構成 の S3 の枠(s3_user_settings.mako L2/L16/L18)。 +DISCONNECT_LINK = ('//div[@id="s3AddonScope"]//div[@id="s3-header"]' + '//a[contains(@class, "default-authorized-by")' + ' and contains(@class, "text-danger")]') +# askDisconnect は bootbox.confirm を出す(addonSettings.js L115-137)。 +# 確認文字列の入力は無く、btn-danger を押すだけ。 +DISCONNECT_CONFIRM = ('//div[contains(@class, "bootbox")]' + '//button[contains(@class, "btn-danger")]') + + +def load_config(): + """.config.yaml から接続情報を読む。**値は印字しない。**""" + if not os.path.exists('.config.yaml'): + print('.config.yaml が無い。リポジトリ直下で実行すること。', + file=sys.stderr) + sys.exit(2) + with open('.config.yaml') as f: + cfg = yaml.safe_load(f) or {} + conf = { + 'rdm_url': cfg.get('rdm_url'), + 'idp_name_1': cfg.get('idp_name_1') or 'GakuNin RDM IdP', + 'idp_username_1': cfg.get('idp_username_1'), + 'idp_password_1': cfg.get('idp_password_1'), + 'transition_timeout': cfg.get('transition_timeout') or 60000, + } + missing = [k for k in ('rdm_url', 'idp_username_1', 'idp_password_1') + if not conf[k]] + if missing: + print(f'.config.yaml に足りないキーがある: {missing}', file=sys.stderr) + sys.exit(2) + return conf + + +async def wait_dashboard_loaded(page, conf, retries=3): + """ダッシュボードのプロジェクト一覧が読み終わるのを待つ。 + + 一覧が出る前に「無い」と判定すると、消し残しを「消えた」と誤報告する。 + ノートブックのセル [26] と同じ理由で待つ。 + """ + timeout = conf['transition_timeout'] + loaded = page.locator( + '//*[@data-test-dashboard-item-title]' + ' | //*[contains(text(), "まだプロジェクトがありません")]').first + create_btn = page.locator('//*[@data-test-create-project-modal-button]') + no_api = page.locator('//*[@data-analytics-scope = "No api page"]') + for attempt in range(1, retries + 1): + reason = None + try: + await expect(create_btn.or_(no_api).first + ).to_be_visible(timeout=timeout) + if await no_api.count() > 0: + reason = 'Ember が「APIは利用できません」を出している' + else: + await expect(loaded).to_be_visible(timeout=timeout) + return + except AssertionError: + reason = 'プロジェクト一覧がスピナーのまま' + if attempt == retries: + raise AssertionError( + f'ダッシュボードが読み込まれない({reason})。現在地: {page.url}') + print(f'[!] {reason}。再読込する ({attempt}/{retries - 1})') + await asyncio.sleep(30) + await page.goto(conf['rdm_url']) + + +async def list_projects(page, conf): + await page.goto(conf['rdm_url']) + await wait_dashboard_loaded(page, conf) + return await page.locator( + '//*[@data-test-dashboard-item-title]').all_text_contents() + + +def plan(args): + return [f'{args.prefix}-{s}' for s in SUFFIXES] + + +async def delete_one(page, conf, name): + """ダッシュボードから name を開いて削除する。無ければ False。""" + await page.goto(conf['rdm_url']) + await wait_dashboard_loaded(page, conf) + item = page.locator( + f'//*[@data-test-dashboard-item-title and text()="{name}"]') + if await item.count() == 0: + print(f'skip (既に無い): {name}') + return False + await item.first.click(timeout=conf['transition_timeout']) + await expect(page.locator('#projectNavFiles') + ).to_be_visible(timeout=conf['transition_timeout']) + await grdm.delete_project(page, transition_timeout=conf['transition_timeout']) + await asyncio.sleep(5) + print(f'削除した: {name}') + return True + + +async def disconnect_s3(page, conf, out_dir, dry_run): + """ユーザー設定 → アドオンアカウント構成 の Amazon S3 を解除する。 + + アドオンのアカウントは**ユーザー単位**で残るので、プロジェクトを消しても + 消えない。ここで解除しないと次回の実行が前回の資格情報を掴む。 + """ + timeout = conf['transition_timeout'] + await page.goto(conf['rdm_url'].rstrip('/') + '/settings/addons/') + await expect(page.locator('//div[@id="s3AddonScope"]') + ).to_be_visible(timeout=timeout) + await asyncio.sleep(3) + if out_dir: + await save_screenshot(os.path.join(out_dir, 'S-9-addons-before.png')) + link = page.locator(DISCONNECT_LINK) + count = await link.count() + if count == 0: + print('skip (S3 の連携アカウントが無い)') + return False + if dry_run: + print(f'[dry-run] S3 の連携を解除する (アカウント {count} 件)') + return False + await link.first.scroll_into_view_if_needed() + await link.first.click() + confirm = page.locator(DISCONNECT_CONFIRM) + await expect(confirm.first).to_be_visible(timeout=timeout) + await confirm.first.click() + await asyncio.sleep(3) + # 解除できていなければリンクが残る。残ったまま「解除した」と書かない。 + await page.goto(conf['rdm_url'].rstrip('/') + '/settings/addons/') + await expect(page.locator('//div[@id="s3AddonScope"]') + ).to_be_visible(timeout=timeout) + await asyncio.sleep(3) + remain = await page.locator(DISCONNECT_LINK).count() + assert remain == 0, f'S3 の連携が解除できていない(残り {remain} 件)' + if out_dir: + await save_screenshot(os.path.join(out_dir, 'S-9-addons-after.png')) + print('S3 の連携を解除した') + return True + + +async def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--prefix', required=True, + help='プロジェクト名の接頭辞 ' + '(例 TEST-S3-SIGV4-20261001-130318)') + parser.add_argument('--out', default=None, + help='証跡の保存先ディレクトリ') + parser.add_argument('--dry-run', action='store_true', + help='削除も解除もせず、対象の有無と画面だけを採る') + parser.add_argument('--skip-addon', action='store_true', + help='S3 アドオンの連携解除を行わない') + args = parser.parse_args() + + conf = load_config() + targets = plan(args) + out_dir = os.path.abspath(args.out) if args.out else None + session_dir = None + if out_dir: + os.makedirs(out_dir, exist_ok=True) + # playwright 側の session 証跡(video-N.webm / har.zip / console.log / + # last-*.html|png)は**固定名**なので、証跡ディレクトリ直下を last_path に + # すると本番の採取が残した同名ファイルを上書きする(実際に 1 回消した)。 + # 後始末ぶんは下位ディレクトリに分ける。 + session_dir = os.path.join(out_dir, 'cleanup') + os.makedirs(session_dir, exist_ok=True) + print('対象プロジェクト:') + for t in targets: + print(f' - {t}') + print(f'証跡: {out_dir or "(保存しない)"}') + print(f'dry-run: {args.dry_run}') + print('機関ストレージには触らない') + + await init_pw_context(close_on_fail=False, last_path=session_dir) + try: + async def _login(page): + # about:blank のまま login を呼ぶと IdP 選択に届かず、 + # 「ユーザー名とパスワードによるログインを試みます...」の + # フォールバックも空振りする。ノートブックのセル [24] と同じ順で開く。 + await page.goto(conf['rdm_url']) + await page.wait_for_load_state('networkidle') + consent = page.locator('//button[text() = "同意する"]') + if await consent.count() > 0 and await consent.is_visible(): + await consent.click() + await page.wait_for_load_state('networkidle') + await grdm.expect_anonymous_toppage( + page, conf['idp_name_1'], + transition_timeout=conf['transition_timeout']) + await grdm.login(page, conf['idp_name_1'], conf['idp_username_1'], + conf['idp_password_1'], + transition_timeout=conf['transition_timeout']) + await expect( + page.locator('//*[@data-test-create-project-modal-button]') + ).to_be_visible(timeout=conf['transition_timeout']) + await run_pw(_login) + + async def _before(page): + names = await list_projects(page, conf) + found = [t for t in targets if t in names] + print(f'削除前に見えているもの: {found}') + missing = [t for t in targets if t not in names] + if missing: + print(f'一覧に無いもの(既に削除済みか別ユーザー): {missing}') + if out_dir: + await save_screenshot( + os.path.join(out_dir, 'S-9-projects-before.png')) + await run_pw(_before) + + if not args.dry_run: + async def _delete(page): + for name in targets: + await delete_one(page, conf, name) + await run_pw(_delete) + + if not args.skip_addon: + async def _addon(page): + await disconnect_s3(page, conf, out_dir, args.dry_run) + await run_pw(_addon) + + async def _after(page): + names = await list_projects(page, conf) + left = [t for t in targets if t in names] + if out_dir: + await save_screenshot( + os.path.join(out_dir, 'S-9-cleanup.png')) + print(f'削除後に残っているもの: {left}') + assert args.dry_run or not left, \ + f'削除できていないプロジェクトがある: {left}' + await run_pw(_after) + finally: + await finish_pw_context(screenshot=True, last_path=session_dir) + print('後始末を終えた') + + +if __name__ == '__main__': + asyncio.run(main())